create-linkdesk-plugin 0.1.2 → 0.1.3
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/index.js +134 -4
- package/package.json +2 -2
- package/template/.github/workflows/ci.yml +45 -0
- package/template/README.md +10 -0
- package/template/package.json +8 -2
- package/template/plugin.json +3 -3
- package/template/scripts/ci-verify.mjs +483 -0
- package/template/tsconfig.json +14 -14
- package/template/vitest.config.ts +34 -0
- package/template/vitest.setup.ts +96 -0
package/index.js
CHANGED
|
@@ -3,14 +3,23 @@
|
|
|
3
3
|
* create-linkdesk-plugin——LinkDesk 插件脚手架 CLI(纯 Node,零依赖,对标 yo code)。
|
|
4
4
|
*
|
|
5
5
|
* 用法:
|
|
6
|
-
* npm create linkdesk-plugin my-cool-plugin
|
|
7
|
-
* npm create linkdesk-plugin
|
|
6
|
+
* npm create linkdesk-plugin my-cool-plugin # 直接给名字(kebab-case,非交互)
|
|
7
|
+
* npm create linkdesk-plugin # 不带参数 → 交互式询问插件名
|
|
8
|
+
* npm create linkdesk-plugin my-cool-plugin --no-git # 跳过建仓
|
|
8
9
|
*
|
|
9
|
-
* 行为:把同目录 template/ 复制到 <cwd>/<name
|
|
10
|
+
* 行为:把同目录 template/ 复制到 <cwd>/<name>,占位符替换成真实值,**按 `cargo new` 的语义决定
|
|
11
|
+
* 建不建 git 仓**,再打印下一步提示。
|
|
10
12
|
* 占位符:{{pluginName}} {{displayName}} {{author}} {{date}}(递归替换所有模板文件)。
|
|
11
13
|
* {{date}} 注入 CHANGELOG.md 的初始段标题——格式必须是 `## v<版本>(YYYY-MM-DD)`,
|
|
12
14
|
* 那是市场「更改日志」页签切段的解析依据(见 docs/02-Electron架构/.../插件规范化层/02)。
|
|
13
15
|
*
|
|
16
|
+
* 🔴 建仓三语义(E6#103 · L7 第 7.6 轮)——照抄 `cargo new`,**不是「一律 git init」**:
|
|
17
|
+
* ① 目标目录**已在某个 git 仓内** ⇒ 不 init(防嵌套仓——在容器目录里生成插件正是这种情形)
|
|
18
|
+
* ② 不在任何 git 仓内 ⇒ `git init -b main`
|
|
19
|
+
* ③ `--no-git` ⇒ 不建仓(逃生口,对标 `cargo new --vcs none`)
|
|
20
|
+
* 比 cargo 多一步:建完仓**顺手做一次初始提交**——模板自带 `.gitignore`,作者第一步看到的
|
|
21
|
+
* 就不是满屏 untracked,`git log` 也立刻有一笔可回退的基线。不想要仓的人用 `--no-git`。
|
|
22
|
+
*
|
|
14
23
|
* 生成产物契约:见 docs/02-Electron架构/E6_插件生态与发布/02-插件开发工具链/01-create-linkdesk-plugin脚手架.md。
|
|
15
24
|
*/
|
|
16
25
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -24,6 +33,18 @@ const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), "template");
|
|
|
24
33
|
/** kebab-case——同时满足插件 id / viewsContainers key / npm 包名惯例(SAFE_PLUGIN_ID 的形状子集) */
|
|
25
34
|
const NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
26
35
|
|
|
36
|
+
/** 认识的旗标——**显式白名单**:`--nogit` 这种拼错不能静默略过(作者会以为仓建好了) */
|
|
37
|
+
const KNOWN_FLAGS = ["--no-git", "--help", "-h"];
|
|
38
|
+
|
|
39
|
+
const USAGE = [
|
|
40
|
+
"用法:",
|
|
41
|
+
" npm create linkdesk-plugin <name> 生成 <name>/ 插件工程(kebab-case)",
|
|
42
|
+
" npm create linkdesk-plugin 交互式询问插件名",
|
|
43
|
+
"选项:",
|
|
44
|
+
" --no-git 不建 git 仓(对标 cargo new --vcs none)",
|
|
45
|
+
" --help, -h 显示本说明",
|
|
46
|
+
].join("\n");
|
|
47
|
+
|
|
27
48
|
/** 本地日期 YYYY-MM-DD(不用 toISOString——那是 UTC,跨时区会差一天) */
|
|
28
49
|
function todayLocal() {
|
|
29
50
|
const d = new Date();
|
|
@@ -50,6 +71,86 @@ function gitUserName() {
|
|
|
50
71
|
}
|
|
51
72
|
}
|
|
52
73
|
|
|
74
|
+
// ─────────────────────────── git(§〇 建仓三语义) ───────────────────────────
|
|
75
|
+
|
|
76
|
+
/** 跑一条 git 命令——不抛,失败由调用方看 status(没装 git 时 status=null 且 error 有值) */
|
|
77
|
+
function git(args, cwd) {
|
|
78
|
+
try {
|
|
79
|
+
return spawnSync("git", args, { cwd, encoding: "utf8", timeout: 15000 });
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return { status: null, stdout: "", stderr: String(err?.message ?? err) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** git 在不在 PATH 上——不在就不建仓(骨架照常给,不拿「没装 git」卡住作者) */
|
|
86
|
+
function gitAvailable() {
|
|
87
|
+
return git(["--version"]).status === 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 目标目录是否**已在某个 git 仓内**——`cargo new` 的「不造嵌套仓」判据。
|
|
92
|
+
*
|
|
93
|
+
* 在**目标目录里**问(不是 cwd):目录此时已建出来,向上找仓根正是 `cargo new` 的做法。
|
|
94
|
+
* `rev-parse --show-toplevel` 在仓外会 exit 128 ⇒ 那就是「不在仓内」。
|
|
95
|
+
*/
|
|
96
|
+
function insideGitRepo(dir) {
|
|
97
|
+
const r = git(["rev-parse", "--show-toplevel"], dir);
|
|
98
|
+
return r.status === 0 ? (r.stdout || "").trim() : "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** stderr 摘要(给作者看的失败原因,最多三行) */
|
|
102
|
+
function why(r) {
|
|
103
|
+
return ((r.stderr || r.stdout || "").trim().split("\n").filter(Boolean).slice(0, 3).join(" / ")) || "(无输出)";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 建仓——返回结果对象给 CLI 输出(**作者必须知道到底建没建、为什么**,不许自己猜)。
|
|
108
|
+
*
|
|
109
|
+
* 🔴 任何一步失败都**不终止脚手架**:骨架已经生成好了,建仓是加分项,不是前置条件。
|
|
110
|
+
*/
|
|
111
|
+
function setupGit(target, { noGit }) {
|
|
112
|
+
if (noGit) return { kind: "skipped-flag" };
|
|
113
|
+
if (!gitAvailable()) return { kind: "no-git-binary" };
|
|
114
|
+
|
|
115
|
+
const top = insideGitRepo(target);
|
|
116
|
+
if (top) return { kind: "skipped-inside-repo", top };
|
|
117
|
+
|
|
118
|
+
const init = git(["init", "-b", "main"], target);
|
|
119
|
+
if (init.status !== 0) {
|
|
120
|
+
// `-b` 是 git 2.28+ 才有的旗标;更老的 git 退回 init + 显式把 HEAD 指到 main
|
|
121
|
+
// (不这么做,作者第一次 push 会撞上默认分支叫 master 的提示,与 GitHub 默认也不一致)
|
|
122
|
+
if (git(["init"], target).status !== 0) return { kind: "failed", step: "git init", detail: why(init) };
|
|
123
|
+
git(["symbolic-ref", "HEAD", "refs/heads/main"], target);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (git(["add", "-A"], target).status !== 0) return { kind: "init-only", step: "git add" };
|
|
127
|
+
|
|
128
|
+
// `--no-verify`:这是**一个新仓的初始提交**,不该被使用者全局 core.hooksPath 上的
|
|
129
|
+
// commit-msg / pre-commit 钩子审(那些钩子是给别的仓立的规矩)
|
|
130
|
+
const commit = git(["commit", "--no-verify", "-m", "chore: 初始骨架(create-linkdesk-plugin 生成)"], target);
|
|
131
|
+
if (commit.status !== 0) return { kind: "init-only", step: "git commit", detail: why(commit) };
|
|
132
|
+
|
|
133
|
+
return { kind: "created" };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 建仓结果 → CLI 那一行(`git` 那步发生了什么,说全) */
|
|
137
|
+
function gitLine(res) {
|
|
138
|
+
switch (res.kind) {
|
|
139
|
+
case "created":
|
|
140
|
+
return " ✔ 已建 git 仓(main 分支 + 一次初始提交)";
|
|
141
|
+
case "skipped-flag":
|
|
142
|
+
return " · --no-git:未建 git 仓(发布前需要自己 git init)";
|
|
143
|
+
case "skipped-inside-repo":
|
|
144
|
+
return ` · 已在 git 仓内(${res.top})——按 cargo new 语义不建嵌套仓`;
|
|
145
|
+
case "no-git-binary":
|
|
146
|
+
return " ⚠️ 找不到 git(不在 PATH)——未建仓;装上 git 后进目录自己 git init -b main";
|
|
147
|
+
case "init-only":
|
|
148
|
+
return ` ⚠️ 仓已建,但初始提交没成(${res.step}):${res.detail}\n 多半是没配 git 身份 → git config --global user.name "你" && git config --global user.email "you@example.com",再进目录 git commit -m "初始骨架"`;
|
|
149
|
+
default:
|
|
150
|
+
return ` ⚠️ 建仓失败(${res.step}):${res.detail}——进目录自己 git init -b main`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
53
154
|
/** 交互式单问——返回去除首尾空白的答案 */
|
|
54
155
|
function ask(question) {
|
|
55
156
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -79,7 +180,26 @@ function walkReplace(dir, values) {
|
|
|
79
180
|
}
|
|
80
181
|
|
|
81
182
|
async function main() {
|
|
82
|
-
|
|
183
|
+
const argv = process.argv.slice(2);
|
|
184
|
+
const flags = argv.filter((a) => a.startsWith("-"));
|
|
185
|
+
const positional = argv.filter((a) => !a.startsWith("-"));
|
|
186
|
+
|
|
187
|
+
if (flags.includes("--help") || flags.includes("-h")) {
|
|
188
|
+
console.log(USAGE);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const unknown = flags.filter((f) => !KNOWN_FLAGS.includes(f));
|
|
192
|
+
if (unknown.length > 0) {
|
|
193
|
+
console.error(`✖ 不认识的选项:${unknown.join("、")}\n\n${USAGE}`);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
if (positional.length > 1) {
|
|
197
|
+
console.error(`✖ 只接受一个插件名,收到 ${positional.length} 个:${positional.join("、")}\n\n${USAGE}`);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
const noGit = flags.includes("--no-git");
|
|
201
|
+
|
|
202
|
+
let name = (positional[0] || "").trim();
|
|
83
203
|
if (!name) {
|
|
84
204
|
name = await ask("插件名(kebab-case,如 my-cool-plugin): ");
|
|
85
205
|
}
|
|
@@ -115,8 +235,12 @@ async function main() {
|
|
|
115
235
|
};
|
|
116
236
|
walkReplace(target, values);
|
|
117
237
|
|
|
238
|
+
// 建仓放在**最后**——此刻工作区已是终态,初始提交提交的就是作者拿到的那个骨架
|
|
239
|
+
const repo = setupGit(target, { noGit });
|
|
240
|
+
|
|
118
241
|
console.log("");
|
|
119
242
|
console.log(`✔ ${name}/ 已创建`);
|
|
243
|
+
console.log(gitLine(repo));
|
|
120
244
|
console.log("");
|
|
121
245
|
console.log(" 接下来:");
|
|
122
246
|
console.log(` cd ${name}`);
|
|
@@ -124,6 +248,12 @@ async function main() {
|
|
|
124
248
|
console.log(" npm run dev # 浏览器热重载预览(改代码即时生效)");
|
|
125
249
|
console.log(" npm run validate # 校验 plugin.json($schema / 字段 / i18n 文件)");
|
|
126
250
|
console.log(" npm run build # 打包出 <pluginId>.linkdesk-plugin,可装进 LinkDesk / 发布");
|
|
251
|
+
if (repo.kind === "created") {
|
|
252
|
+
console.log("");
|
|
253
|
+
console.log(" 要发布(npm run publish)时还需要一个 GitHub 远端:");
|
|
254
|
+
console.log(" git remote add origin git@github.com:<你>/<仓库>.git");
|
|
255
|
+
console.log(" git push -u origin main");
|
|
256
|
+
}
|
|
127
257
|
console.log("");
|
|
128
258
|
console.log(" 然后:先读 README.md —— 目录该放哪、三条纪律、怎么发布都在里面。");
|
|
129
259
|
console.log(" plugin.json 的 name / description / author 是你的身份信息,src/index.tsx 是插件本体。");
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-linkdesk-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "LinkDesk 插件脚手架——`npm create linkdesk-plugin my-cool-plugin` 一行生成你的第一个插件项目(对标 yo code)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"create-linkdesk-plugin": "
|
|
7
|
+
"create-linkdesk-plugin": "index.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"index.js",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# LinkDesk 插件仓 CI(E6#102)——「搬出去不等于脱管」。
|
|
2
|
+
#
|
|
3
|
+
# 为什么有这份文件:插件源码住在**自己的仓**里,壳仓的 `npm run check` 够不着它(那是壳的门禁)。
|
|
4
|
+
# 类型 / lint / 测试 / i18n / 主题审计这些检查必须在**插件仓自己**跑一遍,否则「独立」是拿
|
|
5
|
+
# 「质量真空」换的。本文件 + `npm run verify` 就是把那一份装回来。
|
|
6
|
+
#
|
|
7
|
+
# 四步各自对应一类「本该在提交前拦住」的错误:
|
|
8
|
+
# validate —— plugin.json 过 schema(身份/contributes 形状)+ 声明的 i18n 字典文件真的在
|
|
9
|
+
# verify —— 严格门禁(内含 SDK lint 全腿;WARN 转拦)。见 scripts/ci-verify.mjs 头注
|
|
10
|
+
# build —— 真的能产出 .linkdesk-plugin(构建期错误在这里现形,不是等用户装的时候)
|
|
11
|
+
# test —— 工程自带测试(没写测试的工程自动跳过,不是失败)
|
|
12
|
+
#
|
|
13
|
+
# 🔴 这份文件**不用任何 `${{ … }}` 表达式**——它是被脚手架当模板复制出去的文本,而壳仓的
|
|
14
|
+
# `check-scaffold.mjs` 会把残留的 `{{…}}` 当「未替换占位符」判红。需要表达式时先去那道门禁
|
|
15
|
+
# 登记(它已对 `.github/**` 开了口子,说明见该脚本)。
|
|
16
|
+
#
|
|
17
|
+
# node 版本对齐壳仓 engines(>=24):插件与壳跑在同一个 Pool 里,别让语法/crypto 行为分叉。
|
|
18
|
+
name: ci
|
|
19
|
+
|
|
20
|
+
on:
|
|
21
|
+
push:
|
|
22
|
+
pull_request:
|
|
23
|
+
|
|
24
|
+
jobs:
|
|
25
|
+
check:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
|
|
30
|
+
- uses: actions/setup-node@v4
|
|
31
|
+
with:
|
|
32
|
+
node-version: 24
|
|
33
|
+
|
|
34
|
+
# 依赖全部来自公开货架(@linkdesk/plugin-sdk 等作者面包)——本步骤不读 GitHub API,
|
|
35
|
+
# 故无需 GITHUB_TOKEN;将来若加了读 GitHub 的步骤,记得喂 token(共享 runner 的匿名
|
|
36
|
+
# 配额 60 次/时,匿名跑会把门禁打成 403 假红)。
|
|
37
|
+
- run: npm ci
|
|
38
|
+
|
|
39
|
+
- run: npm run validate
|
|
40
|
+
- run: npm run verify
|
|
41
|
+
- run: npm run build
|
|
42
|
+
|
|
43
|
+
# 没写测试的工程直接跳过(--if-present),不是失败——但**必须显式跳过**,
|
|
44
|
+
# 不许把「没跑」伪装成「跑了且绿」。
|
|
45
|
+
- run: npm test --if-present
|
package/template/README.md
CHANGED
|
@@ -45,4 +45,14 @@ npm run publish # 建 GitHub Release + 上传 .linkdesk-plugin + 更新 cata
|
|
|
45
45
|
|
|
46
46
|
首次发布需要 GitHub token(跑一次会引导你填,存在本机)。只预览不动作:`npm run publish -- --dry-run`。
|
|
47
47
|
|
|
48
|
+
发布还要求本工程**已经推到 GitHub**(`publish` 拿工程 origin 的仓库去建 Release):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git remote add origin git@github.com:<你>/<仓库>.git
|
|
52
|
+
git push -u origin main
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
> 脚手架生成时已替你建好本仓(`main` 分支 + 一次初始提交),所以这一步只是接远端。
|
|
56
|
+
> 若生成时带了 `--no-git`,则先自己 `git init -b main` 再提交。
|
|
57
|
+
|
|
48
58
|
> 完整作者文档见 LinkDesk 仓库的 `docs/03-插件制造/`(API 契约 / 生命周期 / contributes / 分发 / UI 写法规约)。
|
package/template/package.json
CHANGED
|
@@ -10,11 +10,17 @@
|
|
|
10
10
|
"build": "linkdesk-plugin-sdk build",
|
|
11
11
|
"publish": "linkdesk-plugin-sdk publish",
|
|
12
12
|
"validate": "linkdesk-plugin-sdk validate",
|
|
13
|
-
"lint": "linkdesk-plugin-sdk lint"
|
|
13
|
+
"lint": "linkdesk-plugin-sdk lint",
|
|
14
|
+
"verify": "node scripts/ci-verify.mjs",
|
|
15
|
+
"test": "vitest run"
|
|
14
16
|
},
|
|
15
17
|
"devDependencies": {
|
|
16
18
|
"@linkdesk/plugin-sdk": "^0.1.0",
|
|
19
|
+
"@testing-library/react": "^16.3.2",
|
|
17
20
|
"@types/react": "^18.3.12",
|
|
18
|
-
"
|
|
21
|
+
"jsdom": "^29.1.1",
|
|
22
|
+
"jsonc-parser": "^3.3.1",
|
|
23
|
+
"typescript": "^5.6.3",
|
|
24
|
+
"vitest": "^4.1.10"
|
|
19
25
|
}
|
|
20
26
|
}
|
package/template/plugin.json
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
"$schema": "./node_modules/@linkdesk/plugin-sdk/schemas/plugin.schema.json",
|
|
10
10
|
|
|
11
11
|
// ── 插件身份 ──
|
|
12
|
-
// 插件 ID
|
|
13
|
-
//
|
|
14
|
-
|
|
12
|
+
// 🔴 插件 ID:**显式声明**。它是安装目录名 / 卸载墓碑 / 更新对账的唯一键,**发布后永不可变**。
|
|
13
|
+
// 它与插件所在的目录名**无关**(目录可以随便改)——所以别拿目录名当它的替身。
|
|
14
|
+
"pluginId": "{{pluginName}}",
|
|
15
15
|
"name": "{{displayName}}", // 显示名——标签页 / 插件详情等 UI 出现处
|
|
16
16
|
"version": "0.1.0", // 语义化版本 x.y.z——市场更新比较靠它;+1 时务必同笔补 CHANGELOG.md 的新段
|
|
17
17
|
"description": "{{displayName}}——我的第一个 LinkDesk 插件", // 一句话描述(插件详情页展示)
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ci-verify——插件仓自检门禁(E6#102 · L7 第 7.5 轮)。挂 `npm run verify`,由 `.github/workflows/ci.yml` 调起。
|
|
4
|
+
*
|
|
5
|
+
* ── 为什么有它 ──
|
|
6
|
+
* 插件源码搬出壳仓之后(E6#99),壳仓的 `npm run check` **够不着它们了**(那份门禁只覆盖壳仓)。
|
|
7
|
+
* 搬走的一共六类检查:编译图 / eslint(含 linkdesk/* 自定义规则)/ vitest / 体量 / i18n / 主题审计。
|
|
8
|
+
* 本脚本 + ci.yml + vitest 配置 = 给插件仓装回来的那一份,否则「独立」就是拿「质量真空」换的。
|
|
9
|
+
*
|
|
10
|
+
* ── 四段(每段独立判红;**没有对象也要说话**,不许静默绿)──
|
|
11
|
+
* ① lint 严格腿 —— `@linkdesk/plugin-sdk` 的 eslint 规则腿 + css/font-scale/spacing 三条扫描腿。
|
|
12
|
+
* 🔴 SDK 的 `npm run lint` 是 **WARN 级、永不 fail**(07 §六·三档:警告不是封锁,
|
|
13
|
+
* 作者本地不被拦——那是刻意的)。CI 要的是**拦截**,所以本段把同一份报告按
|
|
14
|
+
* 「零偏离」判定。**这是「lint 会红」的唯一来源**,别把这段删了换成 `npm run lint`。
|
|
15
|
+
* ② 跨插件 import —— 壳仓 `linkdesk/no-cross-plugin-import` 的**仓外形态**:插件源码不得引用别的插件
|
|
16
|
+
* 仓库/包(相对路径越出本仓根,或裸包名形如 `linkdesk-plugin-*` / `@linkdesk/plugin-*`),
|
|
17
|
+
* package.json 也不得依赖别的插件包。共享代码只经 `@linkdesk/ui`,插件间通信走
|
|
18
|
+
* `window.linkdesk.*`。⚠️ 这条规则**不在** SDK preset 里(preset 只注册 8 条 linkdesk
|
|
19
|
+
* 规则、且全 WARN)——故必须自带(「补进 preset」已登记为待收的账,见 06-门禁与CI.md)。
|
|
20
|
+
* ③ 字典完整性 —— `contributes.i18n` / `contributes.languages` 声明的字典:文件在、可解析、
|
|
21
|
+
* 每个值都是**非空字符串**。另打印「本仓 `t()` key 的自有字典覆盖度」为**黄灯**。
|
|
22
|
+
* ④ 声明自洽 —— 声明必须落在**真实存在的文件**上(E6#102f 的仓内等价物:壳侧读的是随包种子 /
|
|
23
|
+
* 冻结快照,插件仓该有「直接吃自己源码」的那条):`entry` / `icon` / `views[].render`
|
|
24
|
+
* 文件在;`contributes.themes` / `iconThemes` 的数据文件在且过各自的 schema;
|
|
25
|
+
* 主题 recipe 引用的 `linkdesk://<id>/…` 资产在(且 id 就是本插件);floatingPanel
|
|
26
|
+
* 三向自洽(viewId ↔ views[].id ↔ render)。
|
|
27
|
+
*
|
|
28
|
+
* ── 为什么 ③ 的覆盖度只能黄灯(不是漏做)──
|
|
29
|
+
* `t()` 的 key 可以合法地住在**应用级字典**里(`lang-defaults` 插件,运行时由它经 LanguageRegistry
|
|
30
|
+
* 提供)。壳侧 `audit-i18n.mjs` 是把所有字典并成一个集合来判的,而**插件仓物理上看不到别的仓**——
|
|
31
|
+
* 在这里判红必然产生假红(作者写 `t("取消")` 完全合法),而假红会让真红失效(壳侧 audit-i18n 头注
|
|
32
|
+
* 同款理由)。所以:字典**文件本身**的问题判红(③ 上半),**跨仓才能回答**的覆盖度只报告。
|
|
33
|
+
*
|
|
34
|
+
* 用法:node scripts/ci-verify.mjs (工程根 = cwd)
|
|
35
|
+
* 退出码 0 = 四段全过;1 = 有红灯(逐条打印缺什么)
|
|
36
|
+
*/
|
|
37
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
38
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
39
|
+
import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
|
|
40
|
+
import { runPluginLint, renderPluginLintReport } from "@linkdesk/plugin-sdk/eslint";
|
|
41
|
+
import { validateThemeJson, validateIconThemeJson } from "@linkdesk/plugin-sdk";
|
|
42
|
+
|
|
43
|
+
const ROOT = process.cwd();
|
|
44
|
+
const failures = [];
|
|
45
|
+
const fail = (msg) => failures.push(msg);
|
|
46
|
+
const line = (s = "") => console.log(s);
|
|
47
|
+
|
|
48
|
+
/** 工程相对路径(正斜杠)——报错信息里一律用它 */
|
|
49
|
+
const rel = (p) => relative(ROOT, p).split(sep).join("/");
|
|
50
|
+
|
|
51
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".vite", "coverage", "__tests__"]);
|
|
52
|
+
function listFiles(dir, out = []) {
|
|
53
|
+
if (!existsSync(dir)) return out;
|
|
54
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
55
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
56
|
+
const p = join(dir, e.name);
|
|
57
|
+
if (e.isDirectory()) listFiles(p, out);
|
|
58
|
+
else out.push(p);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** 参与 lint / import 扫描的源码文件(测试与 mock 不在其列——它们不受这些纪律约束) */
|
|
63
|
+
const isSourceFile = (p) =>
|
|
64
|
+
/\.(ts|tsx|js|jsx)$/.test(p) && !/\.(test|spec)\./.test(p) && !/\.(fixture|mock)\./.test(p) && !/mock/i.test(p);
|
|
65
|
+
|
|
66
|
+
const sourceFiles = listFiles(join(ROOT, "src")).filter(isSourceFile);
|
|
67
|
+
|
|
68
|
+
line("插件仓自检(ci-verify · E6#102)");
|
|
69
|
+
line("────────────────────────────────────────────────────────────");
|
|
70
|
+
|
|
71
|
+
// ── 读 manifest(JSONC——脚手架允许注释,与 SDK validatePluginJson 同一个解析器)──
|
|
72
|
+
let manifest = null;
|
|
73
|
+
const MANIFEST_PATH = join(ROOT, "plugin.json");
|
|
74
|
+
if (!existsSync(MANIFEST_PATH)) {
|
|
75
|
+
fail("plugin.json 不在工程根——插件身份的唯一来源,缺了后面几段都无从谈起");
|
|
76
|
+
} else {
|
|
77
|
+
const errors = [];
|
|
78
|
+
manifest = parseJsonc(readFileSync(MANIFEST_PATH, "utf8"), errors, { allowTrailingComma: true });
|
|
79
|
+
if (errors.length > 0) {
|
|
80
|
+
fail(`plugin.json 不是合法 JSONC:${errors.map((e) => printParseErrorCode(e.error)).join("、")}`);
|
|
81
|
+
manifest = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** 本插件 id——顶层 `pluginId` 是唯一真源;缺了按目录名兜底(兼容存量插件,黄灯提示) */
|
|
85
|
+
const pluginId = manifest?.pluginId ?? basename(ROOT);
|
|
86
|
+
|
|
87
|
+
// ═══════════════ ① lint 严格腿 ═══════════════
|
|
88
|
+
// ⚠️ 无源码的仓(纯数据插件:主题 / 语言包 / 图标集)SDK 的 `lintFiles` 会抛 AllFilesIgnoredError
|
|
89
|
+
// (匹配到的文件全在忽略表里 ⇒ eslint 视作「全被忽略」)。那是「无对象」,不是失败——但**要说出来**。
|
|
90
|
+
// ⚠️ 「全无 ts/tsx」时连 `.css` 扫描腿也一并跳过——所以**必须**先确认本仓真的没有 .css 可查,
|
|
91
|
+
// 否则就成了「静默漏查」(宁可响亮报错,不许假装查过)。
|
|
92
|
+
const cssFiles = listFiles(ROOT).filter((p) => p.endsWith(".css"));
|
|
93
|
+
let report = null;
|
|
94
|
+
let lintNoObject = null;
|
|
95
|
+
try {
|
|
96
|
+
report = await runPluginLint(ROOT);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
// 该异常的名字/模板/消息三处都可能承载「全被忽略」这个语义(实测:message 里没有错误类名)
|
|
99
|
+
const sig = `${e?.name ?? ""} ${e?.messageTemplate ?? ""} ${e?.message ?? ""}`;
|
|
100
|
+
if (/AllFilesIgnoredError|all-matched-files-ignored|All files matched by .* are ignored/i.test(sig)) {
|
|
101
|
+
if (cssFiles.length > 0) {
|
|
102
|
+
fail(
|
|
103
|
+
`① lint 严格腿:本仓有 ${cssFiles.length} 个 .css 却没有 ts/tsx 源码——` +
|
|
104
|
+
`SDK 的 lintFiles 在「全无 ts/tsx」时会抛,把 .css 扫描腿一起吃掉了。**不许静默放过**:` +
|
|
105
|
+
`给本仓补一份最小 ts 入口,或把这条记进待收的 SDK 账。`,
|
|
106
|
+
);
|
|
107
|
+
} else {
|
|
108
|
+
lintNoObject = "本仓没有 ts/tsx 源码、也没有 .css——SDK 的 lintFiles 在「全无对象」时会抛(已登记为 SDK 的账)";
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (report) process.stdout.write(renderPluginLintReport(report) + "\n");
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 「disable 注释引用了 preset 里没有的规则名」这一类——eslint 把它当 **fatal** 报出来。
|
|
118
|
+
* 已知来源:**壳仓专有**规则名随源码一起搬进了插件仓(实测:serial-monitor 的
|
|
119
|
+
* `src/services/SerialContext/ipc.ts` 里那条 `linkdesk/no-module-level-ipc-listener`——
|
|
120
|
+
* 它只在壳仓的 eslint.config.js 里注册过)。
|
|
121
|
+
* **报告不拦**:这不是代码缺陷,是「规则名归属」问题(SDK preset 认得的名字是另一份名单)。
|
|
122
|
+
* 但必须响亮打印——静默放过才是真问题。
|
|
123
|
+
*/
|
|
124
|
+
const RULE_NOT_FOUND_RE = /^Definition for rule '.*' was not found/;
|
|
125
|
+
/** 按腿取偏离数(label 与 lint.ts 的 legs 一致) */
|
|
126
|
+
const legCount = (label) => report?.legs.find((l) => l.label === label)?.violations.length ?? 0;
|
|
127
|
+
|
|
128
|
+
const allRows = report?.eslintRows ?? [];
|
|
129
|
+
const ruleNotFound = allRows.filter((r) => RULE_NOT_FOUND_RE.test(r.message));
|
|
130
|
+
const strictEslintRows = allRows.filter((r) => !RULE_NOT_FOUND_RE.test(r.message));
|
|
131
|
+
/** 判红的三样:真 eslint 偏离 + css 硬编码腿(硬约束 1 的 .css 半边,eslint 到不了 .css)+ 见下 ②③④ */
|
|
132
|
+
const cssLegViolations = legCount("check-css-hardcode");
|
|
133
|
+
const strictLintViolations = strictEslintRows.length + cssLegViolations;
|
|
134
|
+
/** 只报告不拦的两条腿:字号度量与 4px 节奏——属「审美校准」(SDK 07 §六),存量偏离多且修它们要动插件源码 */
|
|
135
|
+
const advisoryLintViolations = legCount("check-font-scale") + legCount("check-spacing-grid");
|
|
136
|
+
|
|
137
|
+
if (lintNoObject) {
|
|
138
|
+
line(`⏭ ① lint 严格腿:${lintNoObject}——本仓**无对象**(不是「绿」,是「没有可查的东西」)。`);
|
|
139
|
+
} else if (strictLintViolations > 0) {
|
|
140
|
+
fail(
|
|
141
|
+
`① lint 严格腿:${strictLintViolations} 处偏离(eslint ${strictEslintRows.length} + css 硬编码腿 ${cssLegViolations})` +
|
|
142
|
+
`——SDK 的 \`npm run lint\` 只报告不拦,**CI 拦**。逐条见上方报告。`,
|
|
143
|
+
);
|
|
144
|
+
} else {
|
|
145
|
+
line(
|
|
146
|
+
`✅ ① lint 严格腿:eslint 规则腿 ${report.files} 文件 + css 硬编码腿 零偏离` +
|
|
147
|
+
`(本段判红的是「硬约束 1/2 那一档」)。`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (ruleNotFound.length > 0) {
|
|
151
|
+
line(
|
|
152
|
+
` ⚠ ${ruleNotFound.length} 处 disable 注释引用了 preset 里**没有的规则名**(报告不拦)——` +
|
|
153
|
+
`壳仓专有规则名随源码搬进本仓后会是这样:`,
|
|
154
|
+
);
|
|
155
|
+
for (const r of ruleNotFound.slice(0, 5)) line(` ${r.file}:${r.line} ${r.message}`);
|
|
156
|
+
}
|
|
157
|
+
if (advisoryLintViolations > 0) {
|
|
158
|
+
line(
|
|
159
|
+
` ⚠ 附加腿(**报告不拦**):font-scale ${legCount("check-font-scale")} 处 / ` +
|
|
160
|
+
`spacing-grid ${legCount("check-spacing-grid")} 处——字号度量与 4px 节奏属审美校准档` +
|
|
161
|
+
`(SDK 07 §六),逐条见上方报告;确属有意的用标准 disable 注释写明理由。`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ═══════════════ ② 跨插件 import ═══════════════
|
|
166
|
+
/** 提取 import/export/require/动态 import 的模块说明符 */
|
|
167
|
+
const SPEC_PATTERNS = [
|
|
168
|
+
/\bfrom\s*["']([^"']+)["']/g,
|
|
169
|
+
/\bimport\s*["']([^"']+)["']/g,
|
|
170
|
+
/\brequire\s*\(\s*["']([^"']+)["']\s*\)/g,
|
|
171
|
+
/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
|
|
172
|
+
];
|
|
173
|
+
/** 别的插件仓/包的形态——N3 命名规范:**仓名与 npm 包名都是 `linkdesk-plugin-<id>`** */
|
|
174
|
+
const OTHER_PLUGIN_RE = /^linkdesk-plugin-/;
|
|
175
|
+
/**
|
|
176
|
+
* 首方 npm 作用域里**不是插件**的那几个——`@linkdesk/plugin-*` 是 LinkDesk 自己的包作用域
|
|
177
|
+
* (`@linkdesk/plugin-sdk` = 作者 SDK 本体),插件不叫这个名字。别把它误判成「另一个插件」:
|
|
178
|
+
* 排除名单**显式写死**,命中即放行(放行面越小越安全——多写一个名字只会让规则更松)。
|
|
179
|
+
*/
|
|
180
|
+
const FIRST_PARTY_NOT_PLUGIN = /^@linkdesk\/plugin-sdk(\/|$)/;
|
|
181
|
+
/** 该说明符是否指向「另一个插件」——是则给出人话理由 */
|
|
182
|
+
function whyCrossPlugin(spec) {
|
|
183
|
+
if (FIRST_PARTY_NOT_PLUGIN.test(spec)) return null;
|
|
184
|
+
if (OTHER_PLUGIN_RE.test(spec)) return "裸包名指向另一个插件";
|
|
185
|
+
if (/^@linkdesk\/plugin-/.test(spec)) return "裸包名指向另一个插件";
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
/** abs 是否落在 ROOT 内(防路径穿越式互引) */
|
|
189
|
+
const isInside = (abs) => abs === ROOT || abs.startsWith(ROOT + sep);
|
|
190
|
+
|
|
191
|
+
if (sourceFiles.length === 0) {
|
|
192
|
+
line("⏭ ② 跨插件 import:本仓无源码(纯数据插件)——无对象(下面 package.json 那条依赖检查仍然适用)。");
|
|
193
|
+
}
|
|
194
|
+
const crossHits = [];
|
|
195
|
+
for (const file of sourceFiles) {
|
|
196
|
+
const text = readFileSync(file, "utf8");
|
|
197
|
+
for (const re of SPEC_PATTERNS) {
|
|
198
|
+
re.lastIndex = 0;
|
|
199
|
+
let m;
|
|
200
|
+
while ((m = re.exec(text)) !== null) {
|
|
201
|
+
const spec = m[1];
|
|
202
|
+
if (spec.startsWith(".")) {
|
|
203
|
+
// 相对 import——解析出真实路径,越出本仓根 = 指向别的插件树(同仓内互引是合法的)
|
|
204
|
+
const abs = resolve(dirname(file), spec);
|
|
205
|
+
if (!isInside(abs)) crossHits.push(`${rel(file)} → "${spec}"(解析到本仓之外:${abs})`);
|
|
206
|
+
} else if (OTHER_PLUGIN_RE.test(spec)) {
|
|
207
|
+
crossHits.push(`${rel(file)} → "${spec}"(裸包名指向另一个插件)`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// package.json 的依赖声明也算「引用」——import 扫不到的那半(有人只声明不 import 也是在搭耦合)
|
|
213
|
+
const PKG_PATH = join(ROOT, "package.json");
|
|
214
|
+
let selfPkgName = null;
|
|
215
|
+
if (existsSync(PKG_PATH)) {
|
|
216
|
+
const pkg = JSON.parse(readFileSync(PKG_PATH, "utf8"));
|
|
217
|
+
selfPkgName = pkg.name;
|
|
218
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
|
|
219
|
+
for (const dep of Object.keys(pkg[field] ?? {})) {
|
|
220
|
+
if (OTHER_PLUGIN_RE.test(dep) && dep !== selfPkgName) {
|
|
221
|
+
crossHits.push(`package.json ${field} 依赖了另一个插件包:"${dep}"`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (crossHits.length > 0) {
|
|
227
|
+
fail(
|
|
228
|
+
`② 跨插件 import:${crossHits.length} 处指向别的插件——万物皆可插件(换/卸任一插件不能影响其他)。\n` +
|
|
229
|
+
crossHits.map((h) => ` ${h}`).join("\n") +
|
|
230
|
+
`\n 共享代码走 @linkdesk/ui;插件间数据/命令走 window.linkdesk.*(configuration/commands/events)。`,
|
|
231
|
+
);
|
|
232
|
+
} else if (sourceFiles.length > 0) {
|
|
233
|
+
line(`✅ ② 跨插件 import:${sourceFiles.length} 文件零互引(源码 + package.json 依赖声明都查了)。`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ═══════════════ ③ 字典完整性 ═══════════════
|
|
237
|
+
/** 收集 manifest 声明的字典文件——`contributes.i18n`(langId → 路径)与 `contributes.languages[]` */
|
|
238
|
+
function collectDictDecls(m) {
|
|
239
|
+
const out = [];
|
|
240
|
+
const c = m?.contributes;
|
|
241
|
+
if (c && typeof c === "object") {
|
|
242
|
+
const i18n = c.i18n;
|
|
243
|
+
if (i18n && typeof i18n === "object" && !Array.isArray(i18n)) {
|
|
244
|
+
for (const [lang, p] of Object.entries(i18n)) {
|
|
245
|
+
if (typeof p === "string") out.push({ origin: `contributes.i18n.${lang}`, rel: p });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (Array.isArray(c.languages)) {
|
|
249
|
+
for (const l of c.languages) {
|
|
250
|
+
if (l && typeof l.path === "string") out.push({ origin: `contributes.languages[${l.id ?? "?"}]`, rel: l.path });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const dictDecls = manifest ? collectDictDecls(manifest) : [];
|
|
258
|
+
const dictKeys = new Set();
|
|
259
|
+
if (dictDecls.length === 0) {
|
|
260
|
+
line(
|
|
261
|
+
`⏭ ③ 字典完整性:本仓**无字典声明**(contributes.i18n / contributes.languages 都没有)——无对象。` +
|
|
262
|
+
`\n 插件 UI 文案的 key 若由应用级字典(lang-defaults 插件)提供,这是正常形态,不是漏配。`,
|
|
263
|
+
);
|
|
264
|
+
} else {
|
|
265
|
+
const dictProblems = [];
|
|
266
|
+
for (const d of dictDecls) {
|
|
267
|
+
const abs = resolve(ROOT, d.rel);
|
|
268
|
+
if (!isInside(abs)) {
|
|
269
|
+
dictProblems.push(`${d.rel}(${d.origin} 声明的路径越出插件根目录)`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (!existsSync(abs)) {
|
|
273
|
+
dictProblems.push(`${d.rel}(${d.origin} 声明但文件不存在)`);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
let dict;
|
|
277
|
+
try {
|
|
278
|
+
dict = JSON.parse(readFileSync(abs, "utf8"));
|
|
279
|
+
} catch (e) {
|
|
280
|
+
dictProblems.push(`${d.rel} 不是合法 JSON:${e.message}`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (!dict || typeof dict !== "object" || Array.isArray(dict)) {
|
|
284
|
+
dictProblems.push(`${d.rel} 不是一个「key → 文案」对象`);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
for (const [k, v] of Object.entries(dict)) dictKeys.add(k);
|
|
288
|
+
const badValues = Object.entries(dict).filter(([, v]) => typeof v !== "string" || v.trim() === "");
|
|
289
|
+
if (badValues.length > 0) {
|
|
290
|
+
dictProblems.push(
|
|
291
|
+
`${d.rel} 有 ${badValues.length} 个空值/非字符串值:${badValues.slice(0, 5).map(([k]) => k).join("、")}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (dictProblems.length > 0) {
|
|
296
|
+
fail(`③ 字典完整性:${dictProblems.length} 处问题\n${dictProblems.map((p) => ` ${p}`).join("\n")}`);
|
|
297
|
+
} else {
|
|
298
|
+
line(
|
|
299
|
+
`✅ ③ 字典完整性:${dictDecls.length} 个声明的字典文件全部在、可解析、无非空值问题` +
|
|
300
|
+
`(共 ${dictKeys.size} 个 key)。`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** ③ 黄灯:本仓 t() key 的自有字典覆盖度——**只报告不判红**(理由见文件头注) */
|
|
306
|
+
if (sourceFiles.length > 0) {
|
|
307
|
+
const tKeys = new Set();
|
|
308
|
+
for (const file of sourceFiles) {
|
|
309
|
+
const text = readFileSync(file, "utf8");
|
|
310
|
+
for (const m of text.matchAll(/\bt\(\s*(["'])((?:\\.|(?!\1)[^\\\r\n])*)\1/g)) tKeys.add(m[2]);
|
|
311
|
+
}
|
|
312
|
+
const missing = [...tKeys].filter((k) => !dictKeys.has(k) && !/^[\x20-\x7e]*$/.test(k));
|
|
313
|
+
if (tKeys.size === 0) {
|
|
314
|
+
line(" (本仓源码里没有 t() 调用——没有可核的 key。)");
|
|
315
|
+
} else if (missing.length === 0) {
|
|
316
|
+
line(` ✅ 本仓 ${tKeys.size} 个 t() key 全部命中自有字典。`);
|
|
317
|
+
} else {
|
|
318
|
+
line(
|
|
319
|
+
` ⚠ 本仓 ${tKeys.size} 个 t() key 里,有 ${missing.length} 个不在自有字典——` +
|
|
320
|
+
`**黄灯不拦**(key 可能由应用级字典 lang-defaults 提供,插件仓看不到它):`,
|
|
321
|
+
);
|
|
322
|
+
line(` ${missing.slice(0, 8).join("、")}${missing.length > 8 ? ` … 等 ${missing.length} 个` : ""}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ═══════════════ ④ 声明自洽(声明必须落在真实存在的文件上)═══════════════
|
|
327
|
+
if (!manifest) {
|
|
328
|
+
line("⏭ ④ 声明自洽:plugin.json 读不到——本段跳过(上面那条红灯先修)。");
|
|
329
|
+
} else {
|
|
330
|
+
const problems = [];
|
|
331
|
+
const ok = [];
|
|
332
|
+
|
|
333
|
+
/** 声明的相对路径 → 检查文件真的在(本仓根为基准) */
|
|
334
|
+
const checkDeclaredFile = (declRel, origin) => {
|
|
335
|
+
const abs = resolve(ROOT, declRel);
|
|
336
|
+
if (!isInside(abs)) {
|
|
337
|
+
problems.push(`${declRel}(${origin} 声明的路径越出插件根目录)`);
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
if (!existsSync(abs)) {
|
|
341
|
+
problems.push(`${declRel}(${origin} 声明但文件不存在)`);
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// entry / icon / marketIcon——顶层声明(schema 只验形状,不验文件在不在)
|
|
348
|
+
if (typeof manifest.entry === "string") checkDeclaredFile(manifest.entry, "entry");
|
|
349
|
+
for (const key of ["icon", "marketIcon"]) {
|
|
350
|
+
if (typeof manifest[key] === "string") checkDeclaredFile(manifest[key], key);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// contributes.views[][].render——池打开视图时 import 的就是它,缺了当场失败
|
|
354
|
+
const views = manifest.contributes?.views;
|
|
355
|
+
const allViewIds = [];
|
|
356
|
+
if (views && typeof views === "object") {
|
|
357
|
+
for (const [containerId, list] of Object.entries(views)) {
|
|
358
|
+
if (!Array.isArray(list)) continue;
|
|
359
|
+
for (const v of list) {
|
|
360
|
+
if (!v || typeof v !== "object") continue;
|
|
361
|
+
if (typeof v.id === "string") allViewIds.push(v.id);
|
|
362
|
+
if (typeof v.render === "string") {
|
|
363
|
+
checkDeclaredFile(v.render, `contributes.views.${containerId}[${v.id ?? "?"}].render`);
|
|
364
|
+
} else if (v.id !== undefined) {
|
|
365
|
+
problems.push(`contributes.views.${containerId}[${v.id}].render 缺失——池打开该视图时无从 import`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (allViewIds.length > 0) ok.push(`${allViewIds.length} 个视图的 render`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// contributes.themes / iconThemes——数据文件在 + 过各自的 schema(SDK 的公开校验器,与壳侧同 schema)
|
|
373
|
+
const dataLegs = [
|
|
374
|
+
{ key: "themes", label: "主题", validate: validateThemeJson },
|
|
375
|
+
{ key: "iconThemes", label: "图标主题", validate: validateIconThemeJson },
|
|
376
|
+
];
|
|
377
|
+
for (const leg of dataLegs) {
|
|
378
|
+
const list = manifest.contributes?.[leg.key];
|
|
379
|
+
if (!Array.isArray(list) || list.length === 0) continue;
|
|
380
|
+
for (const item of list) {
|
|
381
|
+
if (!item || typeof item.path !== "string") {
|
|
382
|
+
problems.push(`contributes.${leg.key} 有条目缺 path`);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (!checkDeclaredFile(item.path, `contributes.${leg.key}[${item.id ?? "?"}].path`)) continue;
|
|
386
|
+
const res = leg.validate(resolve(ROOT, item.path));
|
|
387
|
+
if (!res.valid) {
|
|
388
|
+
problems.push(
|
|
389
|
+
`${item.path}(${leg.label}数据不符合 schema):\n ` + res.errors.slice(0, 5).join("\n "),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
ok.push(`${list.length} 个${leg.label}数据文件(过 schema)`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 主题 recipe 引用的资产——`linkdesk://<pluginId>/<相对路径>` 必须真在(且 id 就是本插件)
|
|
397
|
+
const themeAssetRefs = new Map();
|
|
398
|
+
const collectRefs = (node, fileRel) => {
|
|
399
|
+
if (typeof node === "string") {
|
|
400
|
+
const m = node.match(/^linkdesk:\/\/([^/]+)\/(.+)$/);
|
|
401
|
+
if (m) themeAssetRefs.set(`${m[1]}|${m[2]}`, fileRel);
|
|
402
|
+
} else if (Array.isArray(node)) {
|
|
403
|
+
node.forEach((n) => collectRefs(n, fileRel));
|
|
404
|
+
} else if (node && typeof node === "object") {
|
|
405
|
+
Object.values(node).forEach((n) => collectRefs(n, fileRel));
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
for (const key of ["themes", "iconThemes"]) {
|
|
409
|
+
for (const item of manifest.contributes?.[key] ?? []) {
|
|
410
|
+
if (!item || typeof item.path !== "string") continue;
|
|
411
|
+
const abs = resolve(ROOT, item.path);
|
|
412
|
+
if (!existsSync(abs)) continue; // 上面已报过
|
|
413
|
+
try {
|
|
414
|
+
collectRefs(JSON.parse(readFileSync(abs, "utf8")), item.path);
|
|
415
|
+
} catch {
|
|
416
|
+
/* JSON 解析问题由 schema 腿报——这里不抢 */
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
for (const [key, fromFile] of themeAssetRefs) {
|
|
421
|
+
const [refId, refPath] = key.split("|");
|
|
422
|
+
if (refId !== pluginId) {
|
|
423
|
+
problems.push(`${fromFile} 引用 "linkdesk://${refId}/…"——插件资产 URL 的 id 段必须是本插件 id "${pluginId}"`);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
checkDeclaredFile(refPath, `${fromFile} 的 linkdesk:// 资产引用`);
|
|
427
|
+
}
|
|
428
|
+
if (themeAssetRefs.size > 0) ok.push(`${themeAssetRefs.size} 处 linkdesk:// 资产引用`);
|
|
429
|
+
|
|
430
|
+
// floatingPanel 声明自洽(壳侧 floatingPanelDeclarers.test.ts 的仓内等价物)
|
|
431
|
+
const fp = manifest.contributes?.floatingPanel;
|
|
432
|
+
if (fp && typeof fp === "object") {
|
|
433
|
+
const viewId = fp.viewId;
|
|
434
|
+
if (typeof viewId !== "string" || viewId.length === 0) {
|
|
435
|
+
problems.push("contributes.floatingPanel.viewId 必须是非空字符串");
|
|
436
|
+
} else {
|
|
437
|
+
if (!allViewIds.includes(viewId)) {
|
|
438
|
+
problems.push(`contributes.floatingPanel.viewId "${viewId}" 不是 contributes.views 里已声明的视图 id`);
|
|
439
|
+
}
|
|
440
|
+
const containerId = Object.keys(views ?? {}).find((k) =>
|
|
441
|
+
(views[k] ?? []).some((v) => v && v.id === viewId && typeof v.render === "string"),
|
|
442
|
+
);
|
|
443
|
+
if (!containerId) {
|
|
444
|
+
problems.push(`contributes.floatingPanel 指向的视图 "${viewId}" 没有声明 render(池打开时无从 import)`);
|
|
445
|
+
} else {
|
|
446
|
+
const loc = manifest.contributes?.viewsContainers?.[containerId]?.location;
|
|
447
|
+
ok.push(`floatingPanel → viewId "${viewId}"(容器 "${containerId}",location=${String(loc)})`);
|
|
448
|
+
if (loc !== "auxiliarybar") {
|
|
449
|
+
line(
|
|
450
|
+
` ℹ floatingPanel 容器 location = ${JSON.stringify(loc)}(壳侧按 auxiliarybar 语义钉着:` +
|
|
451
|
+
`LinkDesk 无该区域渲染,面板走 window.linkdesk.panel.revealFloating 打开)——记录不拦。`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (problems.length > 0) {
|
|
459
|
+
fail(`④ 声明自洽:${problems.length} 处声明指向了不存在/不合规的东西\n${problems.map((p) => ` ${p}`).join("\n")}`);
|
|
460
|
+
} else {
|
|
461
|
+
line(`✅ ④ 声明自洽:${ok.length > 0 ? ok.join("、") + "——全部兑现。" : "本仓无声明对象(无 entry/views/themes)。"}`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// pluginId 缺声明——schema 兜底打黄灯(E6#98g),这里同款提示
|
|
465
|
+
if (!manifest.pluginId) {
|
|
466
|
+
line(
|
|
467
|
+
` ⚠ plugin.json 未显式声明 pluginId——身份现按目录名 "${pluginId}" 兜底。发布后身份不可变,` +
|
|
468
|
+
`显式声明能防「目录改名 = 身份漂移」。`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ═══════════════ 结论 ═══════════════
|
|
474
|
+
line("────────────────────────────────────────────────────────────");
|
|
475
|
+
if (failures.length > 0) {
|
|
476
|
+
console.error(`❌ 插件仓自检未过(${failures.length} 条):`);
|
|
477
|
+
for (const f of failures) console.error(` · ${f}`);
|
|
478
|
+
console.error(`\n SDK 的 \`npm run lint\` 只报告不拦(作者本地哲学);**CI 拦**。修不动的正当偏离用标准`);
|
|
479
|
+
console.error(` eslint-disable 注释 + 理由(见上面报告尾部),别把检查删了。`);
|
|
480
|
+
process.exitCode = 1;
|
|
481
|
+
} else {
|
|
482
|
+
console.log(`✅ 插件仓自检全过(${pluginId})——lint / 跨插件 / 字典 / 声明自洽四段。`);
|
|
483
|
+
}
|
package/template/tsconfig.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2020",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"strict": true,
|
|
7
|
-
"noEmit": true,
|
|
8
|
-
"jsx": "react-jsx",
|
|
9
|
-
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
10
|
-
"types": ["@linkdesk/plugin-sdk"],
|
|
11
|
-
"skipLibCheck": true
|
|
12
|
-
},
|
|
13
|
-
"include": ["src"]
|
|
14
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
10
|
+
"types": ["@linkdesk/plugin-sdk"],
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 插件工程测试环境(E6#102 §二)。
|
|
5
|
+
*
|
|
6
|
+
* 🔴 这份配置**逐项对齐壳仓 `vitest.config.ts`**:`globals` / `environment: "jsdom"` /
|
|
7
|
+
* `setupFiles` 三条是**静默生效**的配置——缺了会报错(那还算好),配错了则是「本地绿、CI 红」,
|
|
8
|
+
* 是这类迁移最典型的坑。改这里前先看壳仓那份,别让两边环境分叉。
|
|
9
|
+
*
|
|
10
|
+
* 两条**刻意不抄**壳仓的地方(写了就是错):
|
|
11
|
+
* - **不设 `@src` / `@` 别名**:壳仓别名是给「与壳同仓的插件」用的;插件源码已外移,
|
|
12
|
+
* `@src` 在本仓物理不可达。插件代码只经 `window.linkdesk.*` 与 `@linkdesk/ui` 拿能力。
|
|
13
|
+
* - **不 include `plugins/**`**:本仓就是一只插件,源码在 `src/`。
|
|
14
|
+
*
|
|
15
|
+
* 🔴 **`server.deps.inline` 是为「仓外形态」新加的一条**(壳仓没有、也不需要):壳仓里
|
|
16
|
+
* `@linkdesk/ui` 解析到**同仓源码**,CSS 由 Vite 顺手处理;插件仓解析到**已发布的 dist**,
|
|
17
|
+
* 而 `dist/index.js` 里有 `import "./index.css"` —— Node 的外部依赖加载器读不了 `.css`,
|
|
18
|
+
* 于是凡经 `@linkdesk/ui` 的测试全部倒在
|
|
19
|
+
* `TypeError: Unknown file extension ".css"`(E6#102 实测:marketplace 6 个文件 / 12 例)。
|
|
20
|
+
* 把 `@linkdesk/ui` 交给 Vite 内联处理后即恢复。**别删这一条**——删了只会在有 UI 组件的
|
|
21
|
+
* 仓里以「莫名其妙的环境错」重现。
|
|
22
|
+
*
|
|
23
|
+
* `passWithNoTests`:还没有测试的工程跑 `vitest run` 不该红——它是「没写」不是「写错了」。
|
|
24
|
+
*/
|
|
25
|
+
export default defineConfig({
|
|
26
|
+
test: {
|
|
27
|
+
globals: true,
|
|
28
|
+
environment: "jsdom",
|
|
29
|
+
setupFiles: ["./vitest.setup.ts"],
|
|
30
|
+
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
|
31
|
+
passWithNoTests: true,
|
|
32
|
+
server: { deps: { inline: ["@linkdesk/ui"] } },
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vitest setup——mock window.linkdesk API。
|
|
3
|
+
* 测试跑在 Node.js/jsdom,没有 Electron preload 注入的 window.linkdesk。
|
|
4
|
+
* 迁移到 linkdesk.* API 后,插件代码直接依赖它——测试环境需提供最小 mock。
|
|
5
|
+
*
|
|
6
|
+
* 🔴 E6#102(L7 第 7.5 轮):**本文件是壳仓 `vitest.setup.ts` 的逐字副本**(除本头注五条)。
|
|
7
|
+
* 它不是「配置」,是插件测试的**运行时地基**——下半部的六个命名空间与 `__ldkConfigStore`
|
|
8
|
+
* 少了任何一个,凡碰 `window.linkdesk` 的测试都会报错、或更糟:静默走错分支。
|
|
9
|
+
* 改壳仓那份时把这里一起改(两处同源)。「由 @linkdesk/plugin-sdk 提供共享版本、
|
|
10
|
+
* 本文件改成一行 re-export」已登记为待收的账——那时这五条注记一并删掉。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// path 纯函数——直接实现,不走 IPC
|
|
14
|
+
const pathMock = {
|
|
15
|
+
normalize: (p: string) => p.replace(/\\/g, "/"),
|
|
16
|
+
join: (...parts: string[]) =>
|
|
17
|
+
parts.map((p) => String(p).replace(/\\/g, "/")).join("/").replace(/\/+/g, "/"),
|
|
18
|
+
basename: (p: string) => {
|
|
19
|
+
const s = p.replace(/\\/g, "/").split("/");
|
|
20
|
+
return s[s.length - 1] || "";
|
|
21
|
+
},
|
|
22
|
+
dirname: (p: string) => {
|
|
23
|
+
const s = p.replace(/\\/g, "/").split("/");
|
|
24
|
+
s.pop();
|
|
25
|
+
return s.join("/") || ".";
|
|
26
|
+
},
|
|
27
|
+
extname: (p: string) => {
|
|
28
|
+
const b = p.replace(/\\/g, "/").split("/").pop() || "";
|
|
29
|
+
const i = b.lastIndexOf(".");
|
|
30
|
+
return i > 0 ? b.slice(i) : "";
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// E5.7#98:测试全局窄类型 cast——替代 (globalThis as any)(__ldkConfigStore 由本文件声明、测试文件消费)
|
|
35
|
+
type TestGlobal = { window?: Window; __ldkConfigStore?: Map<string, unknown> };
|
|
36
|
+
const _g = globalThis as TestGlobal;
|
|
37
|
+
|
|
38
|
+
// configuration——默认返回 null,测试中按需 mock。
|
|
39
|
+
// __ldkConfigStore 暴露给测试——测试可直接设置值控制 get() 返回。
|
|
40
|
+
const _configStore = (_g.__ldkConfigStore = new Map<string, unknown>());
|
|
41
|
+
const configurationMock = {
|
|
42
|
+
get: async (key: string) => _configStore.get(key) ?? null,
|
|
43
|
+
set: async (key: string, v: unknown) => { _configStore.set(key, v); },
|
|
44
|
+
onChange: (_key: string, _cb: (v: unknown) => void) => {
|
|
45
|
+
return () => {}; // no-op unsubscribe
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// workspace——默认返回空工作区
|
|
50
|
+
const workspaceMock = {
|
|
51
|
+
getFolders: async () => [] as { uri: string; name: string }[],
|
|
52
|
+
getActive: async () => undefined as string | undefined,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// filesystem——可替换的最小 stub。测试可覆盖 lk.filesystem.xxx = vi.fn() 按需定制
|
|
56
|
+
const filesystemMock = {
|
|
57
|
+
readTextFile: async (_p: string) => "",
|
|
58
|
+
writeTextFile: async (_p: string, _d: string) => {},
|
|
59
|
+
readBinaryFile: async (_p: string) => new Uint8Array(),
|
|
60
|
+
writeBinaryFile: async (_p: string, _d: Uint8Array) => {},
|
|
61
|
+
listDir: async (_p: string) => [] as { path: string; name: string; isDirectory: boolean; isFile: boolean }[],
|
|
62
|
+
exists: async (_p: string) => false,
|
|
63
|
+
mkdir: async (_p: string) => {},
|
|
64
|
+
copy: async (_src: string, _dest: string) => {},
|
|
65
|
+
remove: async (_p: string) => {},
|
|
66
|
+
watch: async (_dirPath: string, _onEvent: (e: unknown) => void) => {
|
|
67
|
+
return () => {}; // unsubscribe
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// tabs——最小 stub
|
|
72
|
+
const tabsMock = {
|
|
73
|
+
create: async (_type: string, _opts?: Record<string, unknown>) => "tab-1",
|
|
74
|
+
openOrFocus: async (_type: string, _opts?: Record<string, unknown>) => "tab-1",
|
|
75
|
+
focus: async (_tabId: string) => {},
|
|
76
|
+
close: async (_tabId: string) => {},
|
|
77
|
+
focusBySourceId: async (_sourceId: string) => {},
|
|
78
|
+
updateLabelBySourceId: async (_sourceId: string, _label: string) => {},
|
|
79
|
+
closeBySourceId: async (_sourceId: string) => {},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// 最小 mock——故意不满足 LinkDeskAPI 全契约(测试按需覆盖),经 linkdesk?: object 窄口赋值
|
|
83
|
+
_g.window = _g.window ?? ({} as Window);
|
|
84
|
+
(_g.window as Window & { linkdesk?: object }).linkdesk = {
|
|
85
|
+
path: pathMock,
|
|
86
|
+
configuration: configurationMock,
|
|
87
|
+
config: configurationMock,
|
|
88
|
+
workspace: workspaceMock,
|
|
89
|
+
filesystem: filesystemMock,
|
|
90
|
+
tabs: tabsMock,
|
|
91
|
+
// event stubs
|
|
92
|
+
events: {
|
|
93
|
+
on: () => () => {},
|
|
94
|
+
emit: () => {},
|
|
95
|
+
},
|
|
96
|
+
};
|