create-linkdesk-plugin 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,55 +1,62 @@
1
- # create-linkdesk-plugin
2
-
3
- LinkDesk 插件脚手架——一行命令生成你的第一个插件项目(对标 `yo code`)。
4
-
5
- ```bash
6
- npm create linkdesk-plugin my-cool-plugin
7
- ```
8
-
9
- 不带名字则交互式询问:
10
-
11
- ```bash
12
- npm create linkdesk-plugin
13
- ```
14
-
15
- ## 生成什么
16
-
17
- 生成物的形状**与官方插件一致**——README / CHANGELOG / resources / i18n 一个不少:
18
-
19
- ```
20
- my-cool-plugin/
21
- ├── plugin.json # 插件清单(JSONC:可注释/尾逗号,字段分节示范,VS Code $schema 校验)
22
- ├── package.json # scripts: dev / dev:real / build / publish / validate / lint
23
- ├── tsconfig.json # jsx: react-jsx + window.linkdesk.* 类型(@linkdesk/plugin-sdk)
24
- ├── .gitignore # node_modules / dist / *.linkdesk-plugin
25
- ├── README.md # 说明——市场「详情」页签的数据源 + 目录契约表
26
- ├── CHANGELOG.md # 更新日志——市场「更改日志」页签的数据源
27
- ├── .vscode/settings.json # plugin.json 按 jsonc 识别(注释不标红)
28
- ├── resources/
29
- │ └── icon.svg # 图标占位图——换成你自己的
30
- ├── src/
31
- │ ├── index.tsx # 视图组件 default 导出——壳以 { isActive, tabId?, sourceId? } 渲染
32
- │ └── index.css # 样式示例——颜色 / 字号走 var(--xxx),间距走 4px 节奏
33
- └── i18n/
34
- └── en.json # 英文译文(key = 中文原文;不建 zh.json)
35
- ```
36
-
37
- > **不预建空文件夹**(git 本来也不记录空目录)——「东西该放哪」写在生成的 `README.md` 的目录契约表里,
38
- > 用文字说清比用空夹暗示更清楚。
39
-
40
- ## 命令
41
-
42
- ```bash
43
- cd my-cool-plugin
44
- npm install
45
-
46
- npm run dev # 浏览器热重载预览(改代码即时生效)
47
- npm run dev:real # 真机环——直写 {userData}/plugins/<id> + CDP reload(真 IPC / 串口 / LSP 类插件用)
48
- npm run validate # 校验 plugin.json
49
- npm run build # 产出 <pluginId>.linkdesk-plugin——可装进 LinkDesk / 发布
50
- npm run lint # 门禁自检(硬编码颜色 / 字号 / 间距网格 / eslint 规则)
51
- npm run publish # 一键发布(建 GitHub Release + 上传 + 更新 catalog)
52
- ```
53
-
54
- > 说明:插件作者工作流(dev 热预览 / build / 发布全链路)的完整文档见
55
- > [00-第三方作者旅程](../../docs/02-Electron架构/E6_插件生态与发布/05-文档与发布/00-第三方作者旅程.md)。
1
+ # create-linkdesk-plugin
2
+
3
+ The LinkDesk plugin scaffold — one command generates your first plugin project (the `yo code` equivalent).
4
+
5
+ ```bash
6
+ npm create linkdesk-plugin my-cool-plugin
7
+ ```
8
+
9
+ Run it without a name and it asks interactively:
10
+
11
+ ```bash
12
+ npm create linkdesk-plugin
13
+ ```
14
+
15
+ ## What you get
16
+
17
+ The generated project has **the same shape as an official plugin** — README / CHANGELOG / resources / i18n, nothing missing:
18
+
19
+ ```
20
+ my-cool-plugin/
21
+ ├── plugin.json # plugin manifest (JSONC: comments + trailing commas allowed, sectioned example fields, VS Code $schema validation)
22
+ ├── package.json # scripts: dev / dev:real / build / publish / validate / lint / verify / test
23
+ ├── tsconfig.json # jsx: react-jsx + window.linkdesk.* types (@linkdesk/plugin-sdk)
24
+ ├── AGENTS.md # what this project is + the iron rules + where the docs are (for your AI assistant)
25
+ ├── .gitignore # node_modules / dist / *.linkdesk-plugin
26
+ ├── README.md # description — data source for the marketplace "Details" tab + the directory contract table
27
+ ├── CHANGELOG.md # release notes — data source for the marketplace "Changelog" tab
28
+ ├── .github/workflows/ci.yml # CI that runs `npm run verify` on every push
29
+ ├── scripts/ci-verify.mjs # the strict tier CI runs (lint + tests + declaration self-checks)
30
+ ├── .vscode/settings.json # plugin.json is treated as jsonc (comments do not light up red)
31
+ ├── resources/
32
+ │ └── icon.svg # placeholder icon — replace it with your own
33
+ ├── src/
34
+ │ ├── index.tsx # view component, default export — the shell renders it with { isActive, tabId?, sourceId? }
35
+ │ └── index.css # styling example — colors/font sizes via var(--xxx), spacing on a 4px grid
36
+ └── i18n/
37
+ └── en.json # English translations (key = the source string)
38
+ ```
39
+
40
+ > **No empty folders are pre-created** (git does not track them anyway) — "where does this go" is spelled out in the generated `README.md`'s directory contract table. Prose is clearer than a folder-shaped hint.
41
+
42
+ ## Commands
43
+
44
+ ```bash
45
+ cd my-cool-plugin
46
+ npm install
47
+
48
+ npm run dev # browser preview with hot reload (changes apply instantly)
49
+ npm run dev:real # real-device loop — writes into {userData}/plugins/<id> + CDP reload (for plugins needing real IPC / serial / LSP)
50
+ npm run validate # validate plugin.json / theme recipes
51
+ npm run lint # SDK rule tier (hard-coded colors / font sizes / spacing grid / eslint rules)
52
+ npm run test # unit tests (vitest)
53
+ npm run verify # full pre-delivery tier — what CI runs
54
+ npm run build # produce <pluginId>.linkdesk-plugin — installable in LinkDesk / publishable
55
+ npm run publish # one-shot publish (creates the GitHub Release + uploads + updates the catalog)
56
+ ```
57
+
58
+ > The full author documentation (dev preview / build / the whole publishing chain) lives in the LinkDesk repo:
59
+ > the **English tree** at <https://github.com/Encaron/linkdesk/tree/electron/docs/03-plugin-authoring> (start at `00-readme.md`),
60
+ > with the Chinese original at `docs/03-插件制造/`.
61
+
62
+ > 🔴 The generated `AGENTS.md` also carries this pointer, plus the iron rules inline — so the AI working in your project knows where to look without being told.
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 # 直接给名字(kebab-case,非交互)
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
- let name = (process.argv[2] || "").trim();
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.2",
3
+ "version": "0.1.4",
4
4
  "description": "LinkDesk 插件脚手架——`npm create linkdesk-plugin my-cool-plugin` 一行生成你的第一个插件项目(对标 yo code)。",
5
5
  "type": "module",
6
6
  "bin": {
7
- "create-linkdesk-plugin": "./index.js"
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
@@ -0,0 +1,52 @@
1
+ # {{displayName}} — a LinkDesk plugin
2
+
3
+ > **This file is for the AI working in this repository** (Claude Code / Codex / Cursor / …). Humans will prefer `README.md`.
4
+ > Generated by `create-linkdesk-plugin`. Plugin identity = the top-level `pluginId` in `plugin.json` (currently: `{{pluginName}}`).
5
+
6
+ ## 1. What this is
7
+
8
+ A **LinkDesk plugin** project. LinkDesk is a "everything is a plugin" desktop container — **the core is only tabs + split view + a data pipeline + registries, and the core does not know what your plugin does.**
9
+
10
+ Your code runs in a renderer process: use any JS library and any Web API (Canvas / WebGL / wasm / WebRTC / Web Audio / fetch are all open — **there is no API allowlist**). Only **system-level capabilities** (serial ports, files, configuration, dialogs, notifications) must go through `window.linkdesk.*`; do not reach for raw Node.js APIs.
11
+
12
+ ## 2. Iron rules (breaking these breaks the plugin — or the shell)
13
+
14
+ 1. **Colors always via `var(--xxx)`** — never hard-code hex, or your UI will not follow the user's theme.
15
+ 2. **UI text always via `t()`** (key = the source string; English translations go in `i18n/en.json`) — never hard-code display strings.
16
+ 3. **System capabilities only via `window.linkdesk.*`** — never `import` shell internals (`@src/core/...`); the SDK lint fails this at error level.
17
+ 4. **Plugin identity comes only from declarations in `plugin.json`** — never let anyone infer what your plugin is from a directory name or file location.
18
+ 5. **Context menus are declarative** (`contributes.menus` + `<ContextMenu>`); **dialogs portal into `document.body`**; **persistence goes through `window.linkdesk.configuration`** (never `localStorage`).
19
+ 6. **keep-alive architecture: every tab stays mounted** — do not blank content with `isActive`; it is only for gating "run this only while focused" side effects.
20
+
21
+ ## 3. Where to find the rules
22
+
23
+ - **Online (full author documentation, organized by "what I want to do")**: <https://github.com/Encaron/linkdesk/tree/electron/docs/03-plugin-authoring> — start at its `00-readme.md`.
24
+ - **Offline (always available, no network)**: `node_modules/@linkdesk/plugin-sdk/schemas/plugin.schema.json` — the **field-level authority**; `theme.schema.json` next to it does the same for themes.
25
+ - **Editor completion**: `plugin.json`'s `$schema` points at it, so you get completion and diagnostics as you type.
26
+ - **Self-check after editing**: `npm run validate` (manifest/format validity) + `npm run lint` (SDK rule tier).
27
+ - 中文版作者文档(Chinese docs, maintainer-facing original):<https://github.com/Encaron/linkdesk/tree/electron/docs/03-插件制造>
28
+
29
+ ## 4. What to do next (this project's commands, verbatim)
30
+
31
+ ```bash
32
+ npm install # dependencies (first run)
33
+ npm run dev # ① see it in the browser dev host — HMR on save
34
+ npm run dev:real # ② debug inside an installed LinkDesk (real IPC / serial / filesystem)
35
+ npm run validate # ③ validate plugin.json / theme recipes
36
+ npm run lint # ④ SDK rule tier (the mechanical backstop for the iron rules)
37
+ npm run test # ⑤ unit tests (vitest)
38
+ npm run verify # ⑥ full pre-delivery tier — this is what CI runs
39
+ npm run build # ⑦ produce <pluginId>.linkdesk-plugin (single-file zip)
40
+ npm run publish # ⑧ publish to your own GitHub repo (first step of listing)
41
+ ```
42
+
43
+ **🔴 Listing is two steps**: `publish` only completes **step one** (visible to people who manually added your repo as a marketplace source). **Step two is submitting to the official catalog** — only then can every user with default settings find it.
44
+
45
+ ## 5. Two toolchain boundaries
46
+
47
+ - **This project is its own git repo** (the scaffold ran `git init -b main` plus one initial commit). `publish` uses `origin` to create the Release ⇒ pushing to GitHub is just two commands: `git remote add origin <your repo>` + `git push -u origin main`.
48
+ - **Keep the version numbers in step**: `plugin.json`'s `version` and `package.json`'s `version` **must match**; and every bump needs a matching `## v<new version>(YYYY-MM-DD)` section in `CHANGELOG.md`, or the plugin's detail page will show "no changelog provided for this version".
49
+
50
+ ---
51
+
52
+ > Author: {{author}} | Generated: {{date}}
@@ -1,48 +1,56 @@
1
- # {{displayName}}
2
-
3
- > 一句话:这个插件做什么。(写在最上面——LinkDesk 市场的「详情」页签显示的就是本文件)
4
-
5
- <!-- 有场景封面时,把图放到 resources/cover.svg,再取消下面这行的注释:
6
- ![{{displayName}} 场景封面](resources/cover.svg) -->
7
-
8
- ## 怎么用
9
-
10
- 安装后在 LinkDesk 里怎么打开、点哪里、看到什么。写清楚「第一次用的人怎么走通」。
11
-
12
- ## 目录说明——东西该放哪
13
-
14
- 不必预建空文件夹(git 也不记录空目录)。**到需要时再建,位置按下表。**
15
-
16
- | 路径 | 放什么 | 什么时候有 |
17
- |:--|:--|:--|
18
- | `plugin.json` | 插件清单 | **必有** |
19
- | `README.md` | 说明——市场**详情**页签的数据源 | 强烈建议 |
20
- | `CHANGELOG.md` | 更新日志——市场**更改日志**页签的数据源 | 强烈建议 |
21
- | `resources/` | 资产:`icon.svg` / `cover.svg` / README 里引用的图 | 有图就有 |
22
- | `i18n/` | `en.json`(key = 中文原文;**不建 zh.json**) | 有 UI 文案就有 |
23
- | `themes/` · `languages/` · `snippets/` | 数据型插件的载荷 | 数据型才有 |
24
- | `src/index.tsx` | 入口(`plugin.json` 的 `entry`) | 视图插件必有 |
25
- | `src/views/` | 侧栏 / 面板视图组件(`contributes.views` 的 render 指向的文件) | 有视图时 |
26
- | `src/components/` | 本插件内部复用的组件 | 需要时 |
27
- | `src/services/` | 域逻辑 / IPC 封装 / 数据层 | 需要时 |
28
- | `src/styles/` | **多份** CSS 时统一放这(单份且与入口同夹也可) | 需要时 |
29
- | `src/__tests__/` | 单元测试(要测就自己 `npm i -D vitest`,脚手架不预装) | 需要时 |
30
-
31
- > 🔴 **共享的东西不进这里**——跨插件复用的组件 / hook 走 `@linkdesk/ui`(壳提供的公共包),**不要在插件里再写一份**;只属于本插件的域逻辑才留本地。
32
- > 🔴 **资产一律住 `resources/`,插件根不放散图**——能进安装包的是**被 README 引用过**或**被 `icon` / `marketIcon` 声明过**的文件,目录名本身没有魔法。
33
-
34
- ## 写这个插件的三条纪律
35
-
36
- 1. **颜色走主题变量**——CSS 里一律 `var(--xxx)`,**禁硬编码 hex**。理由:LinkDesk 支持整套主题替换,写死颜色 = 换主题后你的插件不跟着变。
37
- 2. **UI 文案走 `t()`**——`t("中文原文")`,英文放 `i18n/en.json`,**不建 `zh.json`**(中文 key 自带兜底)。**只加你真的用 `t()` 读过的 key**——没人读的 key 是死 key。代码标识符(`src/index.tsx` 这类)不是文案,别包进 `t()`。
38
- 3. **插件身份只来自 `plugin.json` 的声明字段**——需要什么能力就声明什么字段(`contributes` / `tabBehavior` / `icon` …),**不要靠目录名或文件位置让别人猜你的插件是什么**。
39
-
40
- ## 发布
41
-
42
- ```bash
43
- npm run publish # 建 GitHub Release + 上传 .linkdesk-plugin + 更新 catalog
44
- ```
45
-
46
- 首次发布需要 GitHub token(跑一次会引导你填,存在本机)。只预览不动作:`npm run publish -- --dry-run`。
47
-
48
- > 完整作者文档见 LinkDesk 仓库的 `docs/03-插件制造/`(API 契约 / 生命周期 / contributes / 分发 / UI 写法规约)。
1
+ # {{displayName}}
2
+
3
+ > One line: what this plugin does. (Keep it at the very top — this file is what the marketplace shows on the **Details** tab.)
4
+
5
+ <!-- When you have cover art, put the image at resources/cover.svg and uncomment the line below:
6
+ ![{{displayName}} cover](resources/cover.svg) -->
7
+
8
+ ## How to use
9
+
10
+ How to open it in LinkDesk, where to click, what you should see. Spell out the path a first-time user walks.
11
+
12
+ ## Directory layout — where things go
13
+
14
+ You do not need to pre-create empty folders (git does not track them). **Create them when you need them; the table below says where.**
15
+
16
+ | Path | What goes here | When it exists |
17
+ |:--|:--|:--|
18
+ | `plugin.json` | The plugin manifest | **Always** |
19
+ | `README.md` | Description — the data source for the marketplace **Details** tab | Strongly recommended |
20
+ | `CHANGELOG.md` | Release notes — the data source for the marketplace **Changelog** tab | Strongly recommended |
21
+ | `resources/` | Assets: `icon.svg` / `cover.svg` / images referenced from the README | Once you have images |
22
+ | `i18n/` | `en.json` (key = the source string; **do not create `zh.json`**) | Once you have UI text |
23
+ | `themes/` · `languages/` · `snippets/` | Payloads for data-only plugins | Data-only plugins |
24
+ | `src/index.tsx` | Entry (the `entry` in `plugin.json`) | Always for view plugins |
25
+ | `src/views/` | Sidebar / panel view components (the files `contributes.views` points at) | Once you have views |
26
+ | `src/components/` | Components reused inside this plugin | When needed |
27
+ | `src/services/` | Domain logic / IPC wrappers / data layer | When needed |
28
+ | `src/styles/` | **Multiple** CSS files — keep them together here (a single file next to the entry is fine too) | When needed |
29
+ | `src/__tests__/` | Unit tests (run `npm i -D vitest` yourself if you want them — the scaffold does not preinstall test tooling) | When needed |
30
+
31
+ > 🔴 **Shared things do not belong here** — components/hooks reused across plugins come from `@linkdesk/ui` (the public package the shell provides). **Do not write a second copy inside your plugin.** Only logic that belongs to this plugin stays local.
32
+ > 🔴 **Assets always live in `resources/` — no loose images in the plugin root.** What gets into the install package is what is **referenced by the README** or **declared by `icon` / `marketIcon`**; the directory name itself has no magic.
33
+
34
+ ## Three rules for this plugin
35
+
36
+ 1. **Colors come from theme variables** — always `var(--xxx)` in CSS, **never a hard-coded hex**. Reason: LinkDesk supports full theme replacement, so a fixed color means your plugin does not follow the theme.
37
+ 2. **UI text goes through `t()`** — `t("source string")`, with English in `i18n/en.json` and **no `zh.json`** (the source string is the key and is its own fallback). **Only add keys you actually read with `t()`** — an unread key is a dead key. Code identifiers (`src/index.tsx` and friends) are not copy — do not wrap them in `t()`.
38
+ 3. **Plugin identity comes only from declared fields in `plugin.json`** — declare whatever capability you need (`contributes` / `tabBehavior` / `icon` …). **Never make other people guess what your plugin is from a directory name or file location.**
39
+
40
+ ## Publishing
41
+
42
+ ```bash
43
+ npm run publish # create the GitHub Release + upload the .linkdesk-plugin + update the catalog
44
+ ```
45
+
46
+ The first publish needs a GitHub token (the command walks you through it once and stores it locally). To see what it would do without doing it: `npm run publish -- --dry-run`.
47
+
48
+ Publishing also requires this project to be **pushed to GitHub** (`publish` uses your project's `origin` to create the Release):
49
+
50
+ ```bash
51
+ git remote add origin git@github.com:<you>/<repo>.git
52
+ git push -u origin main
53
+ ```
54
+
55
+ > The scaffold already created this repository for you (`main` branch + one initial commit), so this step is only about wiring the remote.
56
+ > If you generated with `--no-git`, run `git init -b main` and commit first.
@@ -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
- "typescript": "^5.6.3"
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
  }
@@ -9,9 +9,9 @@
9
9
  "$schema": "./node_modules/@linkdesk/plugin-sdk/schemas/plugin.schema.json",
10
10
 
11
11
  // ── 插件身份 ──
12
- // 插件 ID 默认就是插件目录名({{pluginName}}),所以这里不写。仅当「目录名要改、但安装身份
13
- // 不能变」时才取消下面这行的注释——它是覆盖值,不是必填项。
14
- // "pluginId": "{{pluginName}}",
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
+ }
@@ -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
+ };