@dsh-workbench/module-tools 0.1.9 → 0.1.11
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/package.json +1 -1
- package/src/check.mjs +1 -0
- package/src/checks/contracts.mjs +9 -2
- package/src/checks/kit.mjs +183 -0
- package/src/checks/standalone.mjs +51 -47
- package/src/init.mjs +20 -4
- package/template/README.md.tpl +16 -2
- package/template/package.json.tpl +2 -3
- package/template/scripts/smoke.mjs.tpl +94 -0
- package/template/src/client.tsx.tpl +62 -16
- package/template/src/host.ts.tpl +43 -16
- package/template/src/messages.ts.tpl +12 -0
- package/template/src/plugin.test.ts.tpl +55 -0
- package/template/tsconfig.json +1 -1
- package/template/vitest.config.ts +14 -0
package/package.json
CHANGED
package/src/check.mjs
CHANGED
|
@@ -4,6 +4,7 @@ const CHECKS = {
|
|
|
4
4
|
bundles: { title: '模块产物边界', load: () => import('./checks/bundles.mjs') },
|
|
5
5
|
source: { title: '模块源码边界', load: () => import('./checks/source.mjs') },
|
|
6
6
|
primitives: { title: 'dsh 原语只从模块表拿', load: () => import('./checks/primitives.mjs') },
|
|
7
|
+
kit: { title: '外壳 kit 面', load: () => import('./checks/kit.mjs') },
|
|
7
8
|
cards: { title: '卡片渲染', load: () => import('./checks/cards.mjs') },
|
|
8
9
|
standalone: { title: '独立构建', load: () => import('./checks/standalone.mjs') },
|
|
9
10
|
markers: { title: '公开包无内部标识', load: () => import('./checks/markers.mjs') },
|
package/src/checks/contracts.mjs
CHANGED
|
@@ -61,8 +61,15 @@ for (const pkg of packages) {
|
|
|
61
61
|
if (dsh === undefined) continue;
|
|
62
62
|
declared += 1;
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
// `dsh.bundle.patch` 允许是**一个字符串,也允许是一个数组**(dsh 自己的 dsh-web-app 就是数组:
|
|
65
|
+
// 它的 cordis.patch.yml + 四个 preset 文件全挂在这一处)。这里原来只按字符串处理,
|
|
66
|
+
// 于是第一个用数组的包会把判据**打崩**(ERR_INVALID_ARG_TYPE,堆栈指在 path.join 上,
|
|
67
|
+
// 而真正的问题是"声明形状没接住")—— 外壳加 DIY 的 preset 时踩到。
|
|
68
|
+
const patches = dsh.bundle?.patch;
|
|
69
|
+
for (const patch of patches === undefined ? [] : Array.isArray(patches) ? patches : [patches]) {
|
|
70
|
+
if (!existsSync(join(pkg.dir, patch))) {
|
|
71
|
+
problems.push(`${name}: dsh.bundle.patch 指的 ${patch} 不存在`);
|
|
72
|
+
}
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
if (dsh.client !== undefined) {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// 模块产物从外壳 kit 里取的成员,必须真的在**它承诺给用户的那份外壳**里存在。
|
|
2
|
+
//
|
|
3
|
+
// 起因(2026-10-10 现场):`module-cmdb@0.1.7` 的产物用了 0.1.8 才有的
|
|
4
|
+
// `kit.components.StateBlock` / `Note` / `Activity*`,而 peer 写的是 `^0.1.4`。
|
|
5
|
+
// 装到旧外壳上:顶层解构不抛(`undefined` 也照样解出来),卡片**首帧渲染**时才炸
|
|
6
|
+
// `Element type is invalid`;外壳里没有错误边界 ⇒ 整块面板被 React 卸掉。
|
|
7
|
+
// 维护者本机复现不了,因为本机 profile 把外壳 `link:` 到本仓(永远 HEAD)。
|
|
8
|
+
//
|
|
9
|
+
// 两条规则:
|
|
10
|
+
// ① **面差**:产物 bind 的 kit 成员 ⊆ 指定外壳 `client.js` 里那份 kit。
|
|
11
|
+
// 默认用模块包自己装的那份外壳(devDependency);`DSH_WB_CORE_CLIENT=<路径>`
|
|
12
|
+
// 可以指定别的一份 —— 用它验"对 peer 下限那份外壳会不会红"(发布前该跑一遍)。
|
|
13
|
+
// ② **下限自洽**:`peerDependencies['@dsh-workbench/core']` 的下限不许低于
|
|
14
|
+
// `devDependencies` 的下限。你对用户承诺的版本比你自己构建/测试用的还旧时,
|
|
15
|
+
// 产物的面到底够不够,没人验过 —— 这一条就是把"没人验过"变成红灯。
|
|
16
|
+
// 用 `DSH_WB_CORE_CLIENT` 指着下限那份外壳跑(且面差通过)= 验过了,规则②不再报。
|
|
17
|
+
//
|
|
18
|
+
// 判据跟着 `dsh.workbench` 自述清单走;没有 client.js 的包这里没事可查(由 build/契约判据管)。
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { discoverModulePackages } from '../discover.mjs';
|
|
24
|
+
|
|
25
|
+
const SHELL = '@dsh-workbench/core';
|
|
26
|
+
const KIT_SPECIFIER = '@dsh-workbench/core/client';
|
|
27
|
+
|
|
28
|
+
function kitUsageOf(text) {
|
|
29
|
+
const bindings = new Set();
|
|
30
|
+
const BINDING = /(?:var|const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:__toESM\(\s*)?require\(\s*["']@dsh-workbench\/core\/client["']\s*\)/g;
|
|
31
|
+
for (const match of text.matchAll(BINDING)) bindings.add(match[1]);
|
|
32
|
+
if (bindings.size === 0) {
|
|
33
|
+
// 认不出绑定名时不静默放过:退化成"全文里任何 `<id>.kit.` 的 id"。
|
|
34
|
+
for (const match of text.matchAll(/([A-Za-z_$][\w$]*)\.kit\./g)) bindings.add(match[1]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const used = new Map();
|
|
38
|
+
const add = (bucket, member) => {
|
|
39
|
+
if (bucket === undefined || member === undefined || member === '') return;
|
|
40
|
+
if (!used.has(bucket)) used.set(bucket, new Set());
|
|
41
|
+
used.get(bucket).add(member);
|
|
42
|
+
};
|
|
43
|
+
for (const id of bindings) {
|
|
44
|
+
const dotted = new RegExp(`\\b${id}\\.kit\\.([a-z]+)\\.([A-Za-z0-9_$]+)`, 'g');
|
|
45
|
+
for (const match of text.matchAll(dotted)) add(match[1], match[2]);
|
|
46
|
+
const destructured = new RegExp(`\\{([^{}]*)\\}\\s*=\\s*${id}\\.kit\\.([a-z]+)\\s*;`, 'g');
|
|
47
|
+
for (const match of text.matchAll(destructured)) {
|
|
48
|
+
for (const raw of match[1].split(',')) {
|
|
49
|
+
const name = raw.split(':')[0].trim();
|
|
50
|
+
if (name !== '') add(match[2], name);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return used;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function shellKitOf(clientPath) {
|
|
58
|
+
const stub = () => null;
|
|
59
|
+
const seed = new Proxy(
|
|
60
|
+
{ default: stub },
|
|
61
|
+
{ get: (target, key) => (key in target ? target[key] : stub) },
|
|
62
|
+
);
|
|
63
|
+
// react 得给一个**能 extends 的类**:外壳自己那段 `class CardBoundary extends Component`
|
|
64
|
+
// 在工厂体里就跑(错误边界只能这么写),换成箭头函数会当场 "not a constructor"。
|
|
65
|
+
const react = new Proxy(
|
|
66
|
+
{ default: stub, Component: class {} },
|
|
67
|
+
{ get: (target, key) => (key in target ? target[key] : stub) },
|
|
68
|
+
);
|
|
69
|
+
let exports;
|
|
70
|
+
const load = (registration) => {
|
|
71
|
+
exports = registration.factory((specifier) => {
|
|
72
|
+
if (specifier === 'react' || specifier === 'react/jsx-runtime' || specifier === 'react-dom') return react;
|
|
73
|
+
if (specifier.startsWith('@deepseek-ai/')) return seed;
|
|
74
|
+
if (specifier === SHELL) return exports;
|
|
75
|
+
throw new Error(`外壳产物里出现了一个不认识的 require("${specifier}")`);
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
const window = { __ModuleLoader__: { load } };
|
|
79
|
+
// eslint-disable-next-line no-new-func -- 要的就是"按浏览器加载器那样把这份产物跑一遍",静态解析会骗人
|
|
80
|
+
new Function('window', readFileSync(clientPath, 'utf8'))(window);
|
|
81
|
+
return exports?.kit ?? {};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function shellClientPathOf(pkgDir) {
|
|
85
|
+
const override = process.env.DSH_WB_CORE_CLIENT;
|
|
86
|
+
if (override !== undefined && override.trim() !== '') return override.trim();
|
|
87
|
+
try {
|
|
88
|
+
const require = createRequire(join(pkgDir, 'package.json'));
|
|
89
|
+
return join(dirname(require.resolve(`${SHELL}/package.json`)), 'client.js');
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function floorOf(range) {
|
|
96
|
+
if (typeof range !== 'string') return undefined;
|
|
97
|
+
const match = /(\d+)\.(\d+)\.(\d+)/.exec(range);
|
|
98
|
+
if (match === null) return undefined;
|
|
99
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function below(a, b) {
|
|
103
|
+
for (let at = 0; at < 3; at += 1) {
|
|
104
|
+
if (a[at] !== b[at]) return a[at] < b[at];
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const problems = [];
|
|
110
|
+
let inspected = 0;
|
|
111
|
+
let noArtifact = 0;
|
|
112
|
+
let noShell = 0;
|
|
113
|
+
const override = process.env.DSH_WB_CORE_CLIENT;
|
|
114
|
+
|
|
115
|
+
for (const pkg of discoverModulePackages({ root: process.cwd() })) {
|
|
116
|
+
const name = pkg.manifest.name;
|
|
117
|
+
const client = join(pkg.dir, 'client.js');
|
|
118
|
+
if (!existsSync(client)) {
|
|
119
|
+
noArtifact += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
inspected += 1;
|
|
123
|
+
const text = readFileSync(client, 'utf8');
|
|
124
|
+
const used = kitUsageOf(text);
|
|
125
|
+
const uses = used.size > 0 ? text.includes(KIT_SPECIFIER) : false;
|
|
126
|
+
|
|
127
|
+
if (uses) {
|
|
128
|
+
const shellClient = shellClientPathOf(pkg.dir);
|
|
129
|
+
if (shellClient === undefined || !existsSync(shellClient)) {
|
|
130
|
+
noShell += 1;
|
|
131
|
+
} else {
|
|
132
|
+
let kit;
|
|
133
|
+
try {
|
|
134
|
+
kit = shellKitOf(shellClient);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
problems.push(
|
|
137
|
+
`${name}: 取不出这份外壳的 kit(${shellClient})—— ${error instanceof Error ? error.message : String(error)}。` +
|
|
138
|
+
`执行产物是这份判据唯一可信的取数方式;看不懂就别放着不管`,
|
|
139
|
+
);
|
|
140
|
+
kit = undefined;
|
|
141
|
+
}
|
|
142
|
+
if (kit !== undefined) {
|
|
143
|
+
const missing = [];
|
|
144
|
+
for (const [bucket, members] of used) {
|
|
145
|
+
for (const member of [...members].sort()) {
|
|
146
|
+
if (kit[bucket]?.[member] === undefined) missing.push(`kit.${bucket}.${member}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (missing.length > 0) {
|
|
150
|
+
problems.push(
|
|
151
|
+
`${name}: 产物用了这份外壳(${shellClient})里没有的 ${missing.join('、')} —— ` +
|
|
152
|
+
`顶层解构不会抛(拿到 undefined),卡片首帧渲染才炸 "Element type is invalid",` +
|
|
153
|
+
`而外壳没有错误边界 ⇒ 整块面板会跟着没。要么去掉/换掉这些成员,要么把 peer 下限提到真正需要的那版`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const peerFloor = floorOf(pkg.manifest.peerDependencies?.[SHELL]);
|
|
161
|
+
const devFloor = floorOf(pkg.manifest.devDependencies?.[SHELL]);
|
|
162
|
+
if (peerFloor !== undefined && devFloor !== undefined && below(peerFloor, devFloor) && override === undefined) {
|
|
163
|
+
problems.push(
|
|
164
|
+
`${name}: peer 下限 ${pkg.manifest.peerDependencies[SHELL]} 比 devDependency ${pkg.manifest.devDependencies[SHELL]} 还旧 —— ` +
|
|
165
|
+
`你对用户承诺的那版外壳,产物从没对着验过。把 peer 下限提到与 devDependency 同档;` +
|
|
166
|
+
`确实要支持更旧的外壳,就用 DSH_WB_CORE_CLIENT=<那版外壳的 client.js> 跑一遍 check --only kit 证明面够用`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (problems.length > 0) {
|
|
172
|
+
console.error(`✖ 外壳 kit 面 ${problems.length} 条不过:`);
|
|
173
|
+
for (const problem of problems) console.error(` · ${problem}`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const notes = [
|
|
178
|
+
`${inspected} 个模块的产物对过外壳 kit 面`,
|
|
179
|
+
override === undefined ? '(按各自装的那份外壳)' : `(DSH_WB_CORE_CLIENT=${override})`,
|
|
180
|
+
];
|
|
181
|
+
if (noArtifact > 0) notes.push(`${noArtifact} 个还没建产物(跳过)`);
|
|
182
|
+
if (noShell > 0) notes.push(`${noShell} 个没装外壳(跳过面差)`);
|
|
183
|
+
console.log(`✔ 外壳 kit 面通过:${notes.join(' · ')}`);
|
|
@@ -6,60 +6,64 @@ import { discoverModulePackages, discoverPackages } from '../discover.mjs';
|
|
|
6
6
|
const SHELL = '@dsh-workbench/core';
|
|
7
7
|
|
|
8
8
|
const shell = discoverPackages({ root: process.cwd() }).find((pkg) => pkg.manifest.name === SHELL);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
const visible = shell === undefined ? undefined : join(shell.dir, 'src');
|
|
10
|
+
|
|
11
|
+
if (visible === undefined || !existsSync(visible)) {
|
|
12
|
+
console.log(`✔ 独立构建:跳过 —— 本仓没有 ${SHELL} 的源码("不许 @/ 指外壳"那条判据照旧在守)`);
|
|
13
|
+
} else {
|
|
14
|
+
runStandalone(visible);
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const hidden = join(shell.dir, 'src.hidden');
|
|
17
|
+
function runStandalone(visible) {
|
|
18
|
+
const modules = discoverModulePackages({ root: process.cwd() });
|
|
19
|
+
const hidden = join(shell.dir, 'src.hidden');
|
|
17
20
|
|
|
18
|
-
const restore = () => {
|
|
19
|
-
|
|
20
|
-
};
|
|
21
|
-
process.on('SIGINT', () => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
});
|
|
21
|
+
const restore = () => {
|
|
22
|
+
if (existsSync(hidden) && !existsSync(visible)) renameSync(hidden, visible);
|
|
23
|
+
};
|
|
24
|
+
process.on('SIGINT', () => {
|
|
25
|
+
restore();
|
|
26
|
+
process.exit(130);
|
|
27
|
+
});
|
|
25
28
|
|
|
26
|
-
if (existsSync(hidden)) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
29
|
+
if (existsSync(hidden)) {
|
|
30
|
+
console.error(`上次没还原干净:${hidden} 还在,手动改名回去再跑。`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
30
33
|
|
|
31
|
-
const rows = [];
|
|
32
|
-
const problems = [];
|
|
33
|
-
renameSync(visible, hidden);
|
|
34
|
-
try {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
34
|
+
const rows = [];
|
|
35
|
+
const problems = [];
|
|
36
|
+
renameSync(visible, hidden);
|
|
37
|
+
try {
|
|
38
|
+
for (const pkg of modules) {
|
|
39
|
+
const name = pkg.manifest.name;
|
|
40
|
+
for (const script of ['build', 'typecheck']) {
|
|
41
|
+
if (pkg.manifest.scripts?.[script] === undefined) continue;
|
|
42
|
+
try {
|
|
43
|
+
execFileSync('pnpm', ['run', script], { cwd: pkg.dir, stdio: 'pipe' });
|
|
44
|
+
rows.push(`${name} ${script}: ✅`);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const output = `${error.stdout ?? ''}${error.stderr ?? ''}`;
|
|
47
|
+
const first = output
|
|
48
|
+
.split('\n')
|
|
49
|
+
.filter((line) => /error|Cannot find|Could not resolve|ERR_/.test(line))
|
|
50
|
+
.slice(0, 2)
|
|
51
|
+
.map((line) => line.trim())
|
|
52
|
+
.join(' | ');
|
|
53
|
+
rows.push(`${name} ${script}: ❌`);
|
|
54
|
+
problems.push(`${name} ${script} 在外壳源码不在场时失败 —— ${first.slice(0, 220)}`);
|
|
55
|
+
}
|
|
52
56
|
}
|
|
53
57
|
}
|
|
58
|
+
} finally {
|
|
59
|
+
restore();
|
|
54
60
|
}
|
|
55
|
-
} finally {
|
|
56
|
-
restore();
|
|
57
|
-
}
|
|
58
61
|
|
|
59
|
-
if (problems.length > 0) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
if (problems.length > 0) {
|
|
63
|
+
console.error(`✖ 独立构建 ${problems.length} 条不过:`);
|
|
64
|
+
for (const problem of problems) console.error(` · ${problem}`);
|
|
65
|
+
console.error(' (上面的清单就是"发布出去会散架"的地方:模块只许依赖已发布的接口)');
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
console.log(`✔ 独立构建通过:${modules.length} 个模块包在外壳源码不在场时都能 build + typecheck`);
|
|
64
69
|
}
|
|
65
|
-
console.log(`✔ 独立构建通过:${modules.length} 个模块包在外壳源码不在场时都能 build + typecheck`);
|
package/src/init.mjs
CHANGED
|
@@ -2,14 +2,25 @@ import { cpSync, existsSync, readFileSync, readdirSync, rmSync, writeFileSync }
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const TEMPLATES = {
|
|
6
|
+
minimal: '../template',
|
|
7
|
+
diy: '../template-diy',
|
|
8
|
+
};
|
|
6
9
|
const UNIT_ID = /^[a-z][a-z0-9-]*$/;
|
|
7
10
|
|
|
8
11
|
export async function init({ root, args = [] }) {
|
|
9
12
|
const unit = args[0];
|
|
10
13
|
if (unit === undefined || !UNIT_ID.test(unit)) {
|
|
11
|
-
console.error('用法:dsh-wb-module init <unit> [--dir <目录>] [--description <文字>]');
|
|
14
|
+
console.error('用法:dsh-wb-module init <unit> [--dir <目录>] [--template minimal|diy] [--description <文字>]');
|
|
12
15
|
console.error(' unit 是小写短横线标识(它同时是单元 id、包名后缀、卡片 class 前缀)');
|
|
16
|
+
console.error(' --template diy:带一个真数据源 + 三态 + ?wbMock=1 夹具的样板(默认 minimal 是空壳)');
|
|
17
|
+
process.exit(2);
|
|
18
|
+
}
|
|
19
|
+
const picked = args[args.indexOf('--template') + 1];
|
|
20
|
+
const which = args.includes('--template') ? picked : 'minimal';
|
|
21
|
+
const template = TEMPLATES[which];
|
|
22
|
+
if (template === undefined) {
|
|
23
|
+
console.error(`不认识的模板:${String(which)}(可用:${Object.keys(TEMPLATES).join(' / ')})`);
|
|
13
24
|
process.exit(2);
|
|
14
25
|
}
|
|
15
26
|
const at = args.indexOf('--dir');
|
|
@@ -26,7 +37,12 @@ export async function init({ root, args = [] }) {
|
|
|
26
37
|
__DESC__: description,
|
|
27
38
|
};
|
|
28
39
|
const fill = (text) => text.replace(/__[A-Z]+__/g, (token) => tokens[token] ?? token);
|
|
29
|
-
|
|
40
|
+
const templateDir = fileURLToPath(new URL(template, import.meta.url));
|
|
41
|
+
if (!existsSync(templateDir)) {
|
|
42
|
+
console.error(`模板 ${which} 的目录不在:${templateDir}`);
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
cpSync(templateDir, target, { recursive: true });
|
|
30
46
|
const walk = (dir) => {
|
|
31
47
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
32
48
|
const full = join(dir, entry.name);
|
|
@@ -44,5 +60,5 @@ export async function init({ root, args = [] }) {
|
|
|
44
60
|
}
|
|
45
61
|
};
|
|
46
62
|
walk(target);
|
|
47
|
-
console.log(`${target} 建好了:${unit}
|
|
63
|
+
console.log(`${target} 建好了:${unit} 模块骨架(模板 ${which};build / check / selfcheck 已接工具)`);
|
|
48
64
|
}
|
package/template/README.md.tpl
CHANGED
|
@@ -4,7 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
dsh-wb-module build # 出 dist/host.js 与套了 loader 壳的 client.js
|
|
7
|
-
dsh-wb-module check # 契约 / 产物边界 / 源码边界 / 卡片渲染
|
|
7
|
+
dsh-wb-module check # 契约 / 产物边界 / 源码边界 / dsh 原语 / kit 面 / 卡片渲染 / 独立构建 / 内部标识 / 样式归属 / 不指外壳
|
|
8
|
+
dsh-wb-module check --only cards # 十条可以单跑
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
包自带的四个脚本(`check` 的契约判据要求它们都在):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm build # 构建产物
|
|
15
|
+
pnpm typecheck # tsc --noEmit
|
|
16
|
+
pnpm test # vitest(src/**/*.test.ts)
|
|
17
|
+
pnpm selfcheck # 构建 + 把 client.js 当浏览器加载器跑一遍,看卡片真进没进外壳
|
|
8
18
|
```
|
|
9
19
|
|
|
10
20
|
装进本地工作台:
|
|
@@ -15,4 +25,8 @@ dsh plugin --profile workbench add <本目录>
|
|
|
15
25
|
|
|
16
26
|
面板只读、写入只走 agent 工具;共享代码从 `@dsh-workbench/shared` 拿,
|
|
17
27
|
单例机制(hooks / 容器组件 / 读缝)只能从 `@dsh-workbench/core/client` 的 kit 拿。
|
|
18
|
-
|
|
28
|
+
dsh 已有的组件与样式**先从 kit 拿**,别从 DOM + CSS 重画。
|
|
29
|
+
|
|
30
|
+
要发布:把 `package.json` 里的 `private` 去掉,并显式写上发布目标
|
|
31
|
+
(`publishConfig.access: "public"` 或指向内网源的 `publishConfig.registry`)——
|
|
32
|
+
`private: true` 的包不许发到任何源,这是刻意的默认。
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"build": "dsh-wb-module build",
|
|
14
14
|
"typecheck": "tsc --noEmit",
|
|
15
15
|
"test": "vitest run",
|
|
16
|
-
"selfcheck": "dsh-wb-module build &&
|
|
16
|
+
"selfcheck": "dsh-wb-module build && node scripts/smoke.mjs",
|
|
17
17
|
"prepack": "dsh-wb-module build"
|
|
18
18
|
},
|
|
19
19
|
"dsh": {
|
|
@@ -38,6 +38,5 @@
|
|
|
38
38
|
"react": "^19.3.0",
|
|
39
39
|
"typescript": "5.9",
|
|
40
40
|
"vitest": "^5.0.2"
|
|
41
|
-
}
|
|
42
|
-
"publishConfig": { "access": "public" }
|
|
41
|
+
}
|
|
43
42
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// 模块包的接线自检:把 client.js 当成浏览器里的加载器那样跑一遍。
|
|
2
|
+
//
|
|
3
|
+
// 为什么值得有:宿主侧挂没挂上,curl /panels 就能看见;浏览器侧挂没挂上,过去只能靠"刷个页面看看"。
|
|
4
|
+
// 这个脚本复刻 dsh 加载器的**契约**(`window.__ModuleLoader__.load({ id, factory })` + `factory(require)`),
|
|
5
|
+
// 于是"模块注册的卡片到底进没进外壳"变成一条机器判据,而不再是人的眼睛。
|
|
6
|
+
//
|
|
7
|
+
// 负例在最后一段:服务不在的时候必须**报出来**,不许静默通过 —— 静默的模块是最难查的那种坏。
|
|
8
|
+
|
|
9
|
+
const PLUGIN_ID = '__NAME__';
|
|
10
|
+
const UNIT_ID = '__UNIT__';
|
|
11
|
+
const failures = [];
|
|
12
|
+
|
|
13
|
+
function check(name, ok, detail) {
|
|
14
|
+
if (ok) {
|
|
15
|
+
console.log(`✅ ${name}`);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
failures.push(name);
|
|
19
|
+
console.log(`❌ ${name}${detail === undefined ? '' : ` —— ${detail}`}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function moduleRequire(name) {
|
|
23
|
+
if (name === 'react') return { createElement: () => null };
|
|
24
|
+
if (name === '@dsh-workbench/core/client') return { kit: { components: { Card: () => null } } };
|
|
25
|
+
throw new Error(`模块包不该 require ${JSON.stringify(name)}:模块表里只有外壳的 seed 词与外部声明`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let boot = null;
|
|
29
|
+
globalThis.window = {
|
|
30
|
+
__ModuleLoader__: {
|
|
31
|
+
load(entry) {
|
|
32
|
+
boot = entry;
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
await import(new URL('../client.js', import.meta.url).href);
|
|
38
|
+
|
|
39
|
+
check('client.js 调用 __ModuleLoader__.load()', boot !== null);
|
|
40
|
+
check('模块 id 等于包名', boot?.id === PLUGIN_ID, boot?.id);
|
|
41
|
+
if (boot === null) {
|
|
42
|
+
console.error(`\n${failures.length} 条没过`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const pluginExports = boot.factory(moduleRequire);
|
|
47
|
+
check(
|
|
48
|
+
'注入 workbenchUnits(外壳交出来的那个服务名)',
|
|
49
|
+
Array.isArray(pluginExports.inject) && pluginExports.inject.includes('workbenchUnits'),
|
|
50
|
+
String(pluginExports.inject),
|
|
51
|
+
);
|
|
52
|
+
check('导出了 apply', typeof pluginExports.apply === 'function');
|
|
53
|
+
|
|
54
|
+
const faces = [];
|
|
55
|
+
const ctx = {
|
|
56
|
+
get: (name) =>
|
|
57
|
+
name === 'workbenchUnits'
|
|
58
|
+
? {
|
|
59
|
+
registerUnit(face) {
|
|
60
|
+
faces.push(face);
|
|
61
|
+
return () => {};
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
: undefined,
|
|
65
|
+
effect: (fn) => {
|
|
66
|
+
fn();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
pluginExports.apply(ctx);
|
|
71
|
+
check('卡片真的注册进了外壳', faces.length === 1, `注册了 ${faces.length} 个`);
|
|
72
|
+
check(`注册的是 ${UNIT_ID} 单元`, faces[0]?.manifest.id === UNIT_ID, faces[0]?.manifest.id);
|
|
73
|
+
check('卡片是个能画的组件', typeof faces[0]?.card === 'function');
|
|
74
|
+
check('卡片自己带标题与顺序(外壳的看板据此排)', faces[0]?.manifest.card?.order === 90);
|
|
75
|
+
|
|
76
|
+
const warnings = [];
|
|
77
|
+
const realWarn = console.warn;
|
|
78
|
+
console.warn = (...args) => warnings.push(args.join(' '));
|
|
79
|
+
pluginExports.apply({
|
|
80
|
+
get: () => undefined,
|
|
81
|
+
effect: (fn) => {
|
|
82
|
+
fn();
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
console.warn = realWarn;
|
|
86
|
+
|
|
87
|
+
check(
|
|
88
|
+
'负例:外壳没交服务时必须报出来(不许静默通过)',
|
|
89
|
+
warnings.some((line) => line.includes('workbenchUnits')),
|
|
90
|
+
warnings.join(' | '),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
console.log(`\n${failures.length === 0 ? '模块自检通过' : `${failures.length} 条没过`}`);
|
|
94
|
+
if (failures.length > 0) process.exit(1);
|
|
@@ -1,27 +1,73 @@
|
|
|
1
1
|
import { createElement as h } from 'react';
|
|
2
|
+
import { kit } from '@dsh-workbench/core/client';
|
|
3
|
+
import { messages } from './messages';
|
|
2
4
|
|
|
3
5
|
const UNIT_ID = '__UNIT__';
|
|
4
6
|
|
|
7
|
+
interface UnitManifest {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly kind: string;
|
|
10
|
+
readonly card: {
|
|
11
|
+
readonly titleKey: string;
|
|
12
|
+
readonly order: number;
|
|
13
|
+
readonly enabledByDefault: boolean;
|
|
14
|
+
} | null;
|
|
15
|
+
readonly tools: readonly unknown[];
|
|
16
|
+
readonly host: { readonly inject: readonly string[] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Rendered = ReturnType<typeof h>;
|
|
20
|
+
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
|
21
|
+
|
|
22
|
+
interface UnitClientFace {
|
|
23
|
+
readonly manifest: UnitManifest;
|
|
24
|
+
readonly messages: { readonly zh: Readonly<Record<string, string>>; readonly en: Readonly<Record<string, string>> };
|
|
25
|
+
readonly card: (props: { readonly t: Translate; readonly onRefresh: () => void }) => Rendered;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface UnitClientApi {
|
|
29
|
+
registerUnit(face: UnitClientFace): () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ClientContext {
|
|
33
|
+
get<T = unknown>(name: string): T | undefined;
|
|
34
|
+
effect(effect: () => void | (() => void), label?: string): () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const manifest: UnitManifest = {
|
|
38
|
+
id: UNIT_ID,
|
|
39
|
+
kind: 'board',
|
|
40
|
+
card: { titleKey: `${UNIT_ID}.cardTitle`, order: 90, enabledByDefault: true },
|
|
41
|
+
tools: [],
|
|
42
|
+
host: { inject: [] },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const face: UnitClientFace = {
|
|
46
|
+
manifest,
|
|
47
|
+
messages,
|
|
48
|
+
card: (props) => {
|
|
49
|
+
const { Card } = kit.components;
|
|
50
|
+
return h(
|
|
51
|
+
Card,
|
|
52
|
+
{
|
|
53
|
+
t: props.t,
|
|
54
|
+
title: props.t(`${UNIT_ID}.cardTitle`),
|
|
55
|
+
refreshing: false,
|
|
56
|
+
onRefresh: props.onRefresh,
|
|
57
|
+
},
|
|
58
|
+
h('p', null, props.t(`${UNIT_ID}.heading`)),
|
|
59
|
+
h('p', null, props.t(`${UNIT_ID}.body`)),
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
5
64
|
export const inject = ['workbenchUnits'];
|
|
6
65
|
|
|
7
|
-
export function apply(ctx) {
|
|
8
|
-
const units = ctx.get('workbenchUnits');
|
|
66
|
+
export function apply(ctx: ClientContext): void {
|
|
67
|
+
const units = ctx.get<UnitClientApi>('workbenchUnits');
|
|
9
68
|
if (units === undefined) {
|
|
10
69
|
console.warn(`[${UNIT_ID}] 外壳没有提供 workbenchUnits 服务:这张卡片装不进来`);
|
|
11
70
|
return;
|
|
12
71
|
}
|
|
13
|
-
ctx.effect(
|
|
14
|
-
() =>
|
|
15
|
-
units.registerUnit({
|
|
16
|
-
manifest: {
|
|
17
|
-
id: UNIT_ID,
|
|
18
|
-
kind: 'board',
|
|
19
|
-
card: { titleKey: `${UNIT_ID}.cardTitle`, order: 90, enabledByDefault: true },
|
|
20
|
-
tools: [],
|
|
21
|
-
host: { inject: [] },
|
|
22
|
-
},
|
|
23
|
-
card: () => h('section', { className: `wb-${UNIT_ID}` }, h('h2', null, `${UNIT_ID}`)),
|
|
24
|
-
}),
|
|
25
|
-
`${UNIT_ID}: 注册`,
|
|
26
|
-
);
|
|
72
|
+
ctx.effect(() => units.registerUnit(face), `__NAME__: 注册 ${UNIT_ID}`);
|
|
27
73
|
}
|
package/template/src/host.ts.tpl
CHANGED
|
@@ -4,24 +4,51 @@ export const name = '__NAME__';
|
|
|
4
4
|
|
|
5
5
|
export const inject = ['workbenchUnits'];
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
interface UnitManifest {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly kind: string;
|
|
10
|
+
readonly card: {
|
|
11
|
+
readonly titleKey: string;
|
|
12
|
+
readonly order: number;
|
|
13
|
+
readonly enabledByDefault: boolean;
|
|
14
|
+
} | null;
|
|
15
|
+
readonly tools: readonly unknown[];
|
|
16
|
+
readonly host: { readonly inject: readonly string[] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface UnitHostFace {
|
|
20
|
+
readonly manifest: UnitManifest;
|
|
21
|
+
mount(): { routes: Record<string, never>; registerTools(): Array<() => void> };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface UnitRegistry {
|
|
25
|
+
register(face: UnitHostFace): () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface HostContext {
|
|
29
|
+
get<T = unknown>(name: string): T | undefined;
|
|
30
|
+
effect(effect: () => void | (() => void), label?: string): () => void;
|
|
31
|
+
logger?: { warn?(message: string): void } | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const manifest: UnitManifest = {
|
|
35
|
+
id: UNIT_ID,
|
|
36
|
+
kind: 'board',
|
|
37
|
+
card: { titleKey: `${UNIT_ID}.cardTitle`, order: 90, enabledByDefault: true },
|
|
38
|
+
tools: [],
|
|
39
|
+
host: { inject: [] },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const face: UnitHostFace = {
|
|
43
|
+
manifest,
|
|
44
|
+
mount: () => ({ routes: {}, registerTools: () => [] }),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function apply(ctx: HostContext): void {
|
|
48
|
+
const units = ctx.get<UnitRegistry>('workbenchUnits');
|
|
9
49
|
if (units === undefined) {
|
|
10
50
|
ctx.logger?.warn?.(`[${UNIT_ID}] 外壳没有提供 workbenchUnits 服务:这个模块装不进来`);
|
|
11
51
|
return;
|
|
12
52
|
}
|
|
13
|
-
ctx.effect(
|
|
14
|
-
() =>
|
|
15
|
-
units.register({
|
|
16
|
-
manifest: {
|
|
17
|
-
id: UNIT_ID,
|
|
18
|
-
kind: 'board',
|
|
19
|
-
card: { titleKey: `${UNIT_ID}.cardTitle`, order: 90, enabledByDefault: true },
|
|
20
|
-
tools: [],
|
|
21
|
-
host: { inject: [] },
|
|
22
|
-
},
|
|
23
|
-
mount: () => ({ routes: {}, registerTools: () => [] }),
|
|
24
|
-
}),
|
|
25
|
-
`${UNIT_ID}: 注册`,
|
|
26
|
-
);
|
|
53
|
+
ctx.effect(() => units.register(face), `__NAME__: 注册 ${UNIT_ID}`);
|
|
27
54
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const messages = {
|
|
2
|
+
zh: {
|
|
3
|
+
'__UNIT__.cardTitle': '新模块',
|
|
4
|
+
'__UNIT__.heading': '这张卡还是空的',
|
|
5
|
+
'__UNIT__.body': '改 src/client.tsx 画你要的内容,然后跑 dsh-wb-module build。',
|
|
6
|
+
},
|
|
7
|
+
en: {
|
|
8
|
+
'__UNIT__.cardTitle': 'New module',
|
|
9
|
+
'__UNIT__.heading': 'This card is still empty',
|
|
10
|
+
'__UNIT__.body': 'Edit src/client.tsx to render what you need, then run dsh-wb-module build.',
|
|
11
|
+
},
|
|
12
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { apply, inject, name } from './host';
|
|
3
|
+
import { apply as applyClient, inject as injectClient } from './client';
|
|
4
|
+
|
|
5
|
+
vi.mock('@dsh-workbench/core/client', () => ({
|
|
6
|
+
kit: { components: { Card: () => null } },
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
function fakeCtx(service: unknown) {
|
|
10
|
+
const faces: unknown[] = [];
|
|
11
|
+
return {
|
|
12
|
+
faces,
|
|
13
|
+
ctx: {
|
|
14
|
+
get: () => service,
|
|
15
|
+
effect: (effect: () => void | (() => void)) => {
|
|
16
|
+
effect();
|
|
17
|
+
return () => {};
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('__NAME__ 的面', () => {
|
|
24
|
+
it('插件名就是包名', () => {
|
|
25
|
+
expect(name).toBe('__NAME__');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('两侧都只注入外壳交出来的那个服务', () => {
|
|
29
|
+
expect(inject).toEqual(['workbenchUnits']);
|
|
30
|
+
expect(injectClient).toEqual(['workbenchUnits']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('宿主半侧把自己的面注册进外壳', () => {
|
|
34
|
+
const { ctx, faces } = fakeCtx({
|
|
35
|
+
register: (face: unknown) => {
|
|
36
|
+
faces.push(face);
|
|
37
|
+
return () => {};
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
apply(ctx as never);
|
|
41
|
+
expect(faces).toHaveLength(1);
|
|
42
|
+
expect((faces[0] as { manifest: { id: string } }).manifest.id).toBe('__UNIT__');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('浏览器半侧把自己的卡片注册进外壳', () => {
|
|
46
|
+
const { ctx, faces } = fakeCtx({
|
|
47
|
+
registerUnit: (face: unknown) => {
|
|
48
|
+
faces.push(face);
|
|
49
|
+
return () => {};
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
applyClient(ctx as never);
|
|
53
|
+
expect(faces).toHaveLength(1);
|
|
54
|
+
});
|
|
55
|
+
});
|
package/template/tsconfig.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"skipLibCheck": true,
|
|
15
15
|
"isolatedModules": true,
|
|
16
16
|
"verbatimModuleSyntax": true,
|
|
17
|
-
"paths": { "@/modules/__UNIT__/*": ["src/*"] }
|
|
17
|
+
"paths": { "@/modules/__UNIT__/*": ["./src/*"] }
|
|
18
18
|
},
|
|
19
19
|
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*.d.ts"]
|
|
20
20
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { defineConfig } from 'vitest/config';
|
|
3
|
+
|
|
4
|
+
const here = (relative: string): string => fileURLToPath(new URL(relative, import.meta.url));
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
resolve: {
|
|
8
|
+
alias: [
|
|
9
|
+
{ find: /^@\/modules\/__UNIT__\//, replacement: `${here('./src')}/` },
|
|
10
|
+
{ find: /^@\/platforms\/[^/]+\//, replacement: `${here('./src/platforms')}/` },
|
|
11
|
+
],
|
|
12
|
+
},
|
|
13
|
+
test: { environment: 'node', include: ['src/**/*.test.ts', 'src/**/*.test.tsx'] },
|
|
14
|
+
});
|