create-linkdesk-plugin 0.1.1 → 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/LICENSE +21 -0
- package/README.md +55 -55
- package/index.js +266 -136
- package/package.json +2 -2
- package/template/.github/workflows/ci.yml +45 -0
- package/template/.vscode/settings.json +8 -8
- package/template/README.md +10 -0
- package/template/gitignore +8 -8
- package/template/package.json +8 -2
- package/template/plugin.json +3 -3
- package/template/resources/icon.svg +23 -23
- package/template/scripts/ci-verify.mjs +483 -0
- package/template/src/index.css +30 -30
- package/template/src/index.tsx +35 -35
- package/template/vitest.config.ts +34 -0
- package/template/vitest.setup.ts +96 -0
package/template/gitignore
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# 依赖
|
|
2
|
-
node_modules/
|
|
3
|
-
|
|
4
|
-
# 构建中间产物(可随时 npm run build 重建)
|
|
5
|
-
dist/
|
|
6
|
-
|
|
7
|
-
# 分发包——由 GitHub Release 承载,不进你的源码仓
|
|
8
|
-
*.linkdesk-plugin
|
|
1
|
+
# 依赖
|
|
2
|
+
node_modules/
|
|
3
|
+
|
|
4
|
+
# 构建中间产物(可随时 npm run build 重建)
|
|
5
|
+
dist/
|
|
6
|
+
|
|
7
|
+
# 分发包——由 GitHub Release 承载,不进你的源码仓
|
|
8
|
+
*.linkdesk-plugin
|
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 插件", // 一句话描述(插件详情页展示)
|
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
|
2
|
-
<!-- ⚠️ 占位图——请替换成你自己插件的图标(同名同位置覆盖即可,plugin.json 的 icon 已指向本文件)。
|
|
3
|
-
写法与官方插件一致:界面里以 <img src="linkdesk://<id>/resources/icon.svg"> 显示,
|
|
4
|
-
是独立文档 ⇒ 不能用 var(--xxx)(解析不到),颜色必须自含实色。 -->
|
|
5
|
-
<defs>
|
|
6
|
-
<linearGradient id="phi_bg" x1="0" y1="0" x2="1" y2="1">
|
|
7
|
-
<stop offset="0" stop-color="#64748B"/>
|
|
8
|
-
<stop offset="1" stop-color="#334155"/>
|
|
9
|
-
</linearGradient>
|
|
10
|
-
<linearGradient id="phi_glass" x1="0" y1="0" x2="0" y2="1">
|
|
11
|
-
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.28"/>
|
|
12
|
-
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.02"/>
|
|
13
|
-
</linearGradient>
|
|
14
|
-
</defs>
|
|
15
|
-
<!-- 中性灰品牌块:刻意不设计成有含义的图形——它的意思是「这里还没有图」 -->
|
|
16
|
-
<rect x="0" y="0" width="48" height="48" rx="10" fill="url(#phi_bg)"/>
|
|
17
|
-
<rect x="0" y="0" width="48" height="48" rx="10" fill="url(#phi_glass)"/>
|
|
18
|
-
<!-- 虚线框 + 加号 = 「把你的图放这儿」 -->
|
|
19
|
-
<rect x="13" y="13" width="22" height="22" rx="5" fill="none"
|
|
20
|
-
stroke="#FFFFFF" stroke-opacity="0.9" stroke-width="2"
|
|
21
|
-
stroke-dasharray="4 3" stroke-linecap="round"/>
|
|
22
|
-
<path d="M24 19.5v9M19.5 24h9" stroke="#FFFFFF" stroke-width="2.4" stroke-linecap="round"/>
|
|
23
|
-
</svg>
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
|
2
|
+
<!-- ⚠️ 占位图——请替换成你自己插件的图标(同名同位置覆盖即可,plugin.json 的 icon 已指向本文件)。
|
|
3
|
+
写法与官方插件一致:界面里以 <img src="linkdesk://<id>/resources/icon.svg"> 显示,
|
|
4
|
+
是独立文档 ⇒ 不能用 var(--xxx)(解析不到),颜色必须自含实色。 -->
|
|
5
|
+
<defs>
|
|
6
|
+
<linearGradient id="phi_bg" x1="0" y1="0" x2="1" y2="1">
|
|
7
|
+
<stop offset="0" stop-color="#64748B"/>
|
|
8
|
+
<stop offset="1" stop-color="#334155"/>
|
|
9
|
+
</linearGradient>
|
|
10
|
+
<linearGradient id="phi_glass" x1="0" y1="0" x2="0" y2="1">
|
|
11
|
+
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.28"/>
|
|
12
|
+
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.02"/>
|
|
13
|
+
</linearGradient>
|
|
14
|
+
</defs>
|
|
15
|
+
<!-- 中性灰品牌块:刻意不设计成有含义的图形——它的意思是「这里还没有图」 -->
|
|
16
|
+
<rect x="0" y="0" width="48" height="48" rx="10" fill="url(#phi_bg)"/>
|
|
17
|
+
<rect x="0" y="0" width="48" height="48" rx="10" fill="url(#phi_glass)"/>
|
|
18
|
+
<!-- 虚线框 + 加号 = 「把你的图放这儿」 -->
|
|
19
|
+
<rect x="13" y="13" width="22" height="22" rx="5" fill="none"
|
|
20
|
+
stroke="#FFFFFF" stroke-opacity="0.9" stroke-width="2"
|
|
21
|
+
stroke-dasharray="4 3" stroke-linecap="round"/>
|
|
22
|
+
<path d="M24 19.5v9M19.5 24h9" stroke="#FFFFFF" stroke-width="2.4" stroke-linecap="round"/>
|
|
23
|
+
</svg>
|
|
@@ -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/src/index.css
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
/* {{displayName}} 样式示例(`npm run lint` 会照着下面三条查)。
|
|
2
|
-
三条纪律:
|
|
3
|
-
· 颜色一律 var(--xxx),禁硬编码 hex——用户换主题时你的插件要跟着变;
|
|
4
|
-
· 字号一律 var(--font-size-*)——用户调全局字号时它才跟着缩放,裸 px 不会;
|
|
5
|
-
· padding / margin / gap 走 4px 节奏(4 的倍数)。 */
|
|
6
|
-
|
|
7
|
-
.starter {
|
|
8
|
-
padding: 24px;
|
|
9
|
-
font-family: inherit;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
.starter__title {
|
|
13
|
-
margin: 0 0 8px;
|
|
14
|
-
color: var(--text);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
.starter__text {
|
|
18
|
-
margin: 0 0 4px;
|
|
19
|
-
color: var(--text-muted);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
.starter__hint {
|
|
23
|
-
margin: 0;
|
|
24
|
-
color: var(--text-muted);
|
|
25
|
-
font-size: var(--font-size-xs);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
.starter__hint code {
|
|
29
|
-
font-family: var(--font-mono, monospace);
|
|
30
|
-
}
|
|
1
|
+
/* {{displayName}} 样式示例(`npm run lint` 会照着下面三条查)。
|
|
2
|
+
三条纪律:
|
|
3
|
+
· 颜色一律 var(--xxx),禁硬编码 hex——用户换主题时你的插件要跟着变;
|
|
4
|
+
· 字号一律 var(--font-size-*)——用户调全局字号时它才跟着缩放,裸 px 不会;
|
|
5
|
+
· padding / margin / gap 走 4px 节奏(4 的倍数)。 */
|
|
6
|
+
|
|
7
|
+
.starter {
|
|
8
|
+
padding: 24px;
|
|
9
|
+
font-family: inherit;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.starter__title {
|
|
13
|
+
margin: 0 0 8px;
|
|
14
|
+
color: var(--text);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.starter__text {
|
|
18
|
+
margin: 0 0 4px;
|
|
19
|
+
color: var(--text-muted);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.starter__hint {
|
|
23
|
+
margin: 0;
|
|
24
|
+
color: var(--text-muted);
|
|
25
|
+
font-size: var(--font-size-xs);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.starter__hint code {
|
|
29
|
+
font-family: var(--font-mono, monospace);
|
|
30
|
+
}
|