tmagic-editor/scripts/check-headless-dist.mjs
roymondchen 94fae10ae0 feat(editor): 新增 SDK 层并将核心服务迁移至 sdk 目录
- 新增 packages/editor/src/sdk,通过 sdk.ts 对外导出
- 将 services 下的核心服务迁移至 sdk/services
- 工具函数拆分至 sdk/utils,校验逻辑迁移至 sdk/validate,新增 sdk/wiring
- services/sdk-instance.ts 负责在编辑器中实例化 SDK
- 补充 SDK 相关单元测试,新增 docs/api/editor/sdk.md 文档
2026-10-08 19:58:03 +08:00

158 lines
5.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 校验各包 headless 子路径的**发布产物**能在原生 Node 里跑起来。
*
* 单测走 vitest,别名指向 `src`、依赖由 vite 解析,看不到 Node 自己的 ESM 解析规则
* (例如 `dayjs/plugin/utc` 这种无 exports 映射、又不带扩展名的深路径会直接 404)。
* 这里在真实的 node_modules 里 `import` / `require` 一次 dist,把这类只在发布后
* 才暴露的问题挡在构建阶段。
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execa } from 'execa';
const dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(dirname, '..');
/** 每个子路径入口应该导出的若干个符号,顺带验证产物不是空壳 */
const targets = [
{ specifier: '@tmagic/design/headless', expects: ['getDesignConfig', 'appendValidateSuggestion'] },
{ specifier: '@tmagic/form/headless', expects: ['validateForm', 'submitForm', 'registerField', 'builtInFields'] },
{ specifier: '@tmagic/editor/headless', expects: ['editorFields'] },
{ specifier: '@tmagic/editor/sdk', expects: ['createDslSdk', 'DslValidationError'] },
];
/**
* 找工作区里该包自己的目录当 cwd。
*
* 在包根里 `import '@tmagic/xxx/headless'` 走的是 Node 的 self-reference:
* 命中的仍是包自己 `exports` 里声明的那份产物路径,同时深层裸依赖按 realpath
* 从 `<pkg>/node_modules` 解析。比挑一个「依赖了该包的目录」更可靠——依赖方的
* 链接可能指向 registry 上的同名旧版本,那样检查的就是别人的产物了。
*/
const findPackageDir = (specifier) => {
const pkgName = specifier.split('/').slice(0, 2).join('/');
const groups = ['packages', 'runtime', 'vue-components', 'react-components'].filter((group) =>
existsSync(path.join(root, group)),
);
for (const group of groups) {
for (const name of readdirSync(path.join(root, group))) {
const manifest = path.join(root, group, name, 'package.json');
if (!existsSync(manifest)) continue;
if (JSON.parse(readFileSync(manifest, 'utf-8')).name === pkgName) {
return path.join(root, group, name);
}
}
}
};
const run = async (cwd, code) => {
const { stdout } = await execa('node', ['--input-type=module', '-e', code], { cwd });
return stdout;
};
let failed = 0;
for (const { specifier, expects } of targets) {
const cwd = findPackageDir(specifier);
if (!cwd) {
console.error(`✗ ${specifier}: 工作区里找不到这个包`);
failed += 1;
continue;
}
const assertExports = `
const missing = ${JSON.stringify(expects)}.filter((name) => mod[name] === undefined);
if (missing.length) throw new Error('missing exports: ' + missing.join(', '));
`;
for (const [kind, code] of [
['import', `const mod = await import('${specifier}');${assertExports}`],
[
'require',
`const { createRequire } = await import('node:module');
const mod = createRequire(process.cwd() + '/index.js')('${specifier}');${assertExports}`,
],
]) {
try {
await run(cwd, code);
console.log(`✓ ${kind} '${specifier}'`);
} catch (error) {
failed += 1;
console.error(`✗ ${kind} '${specifier}'\n${error.stderr || error.message}`);
}
}
}
/**
* sdk 链路必须能在无 DOM 的 Node 里运行:沿着 `dist/es/sdk.js` 的相对 import 走一遍,
* 确认没有引入编辑器界面、画布、UI 库或 `.vue` 组件。
* vue 本身不在禁用之列:`@tmagic/form/headless` 只用到它的响应式 API。
*/
const SDK_FORBIDDEN = [
/^@tmagic\/stage/,
/^@tmagic\/design$/,
/^@tmagic\/form$/,
/^@tmagic\/table/,
/^monaco-editor/,
/\.vue/,
];
const checkSdkImports = () => {
const dir = findPackageDir('@tmagic/editor/sdk');
if (!dir) return;
const esDir = path.join(dir, 'dist/es');
const entry = path.join(esDir, 'sdk.js');
if (!existsSync(entry)) {
console.error(`✗ sdk 产物不存在:${entry}`);
failed += 1;
return;
}
const seen = new Set();
const queue = [entry];
const violations = [];
const importRe = /(?:import|export)\s[^'"]*?from\s*['"]([^'"]+)['"]|import\s*['"]([^'"]+)['"]/g;
while (queue.length) {
const file = queue.pop();
if (seen.has(file)) continue;
seen.add(file);
const code = readFileSync(file, 'utf-8');
for (const match of code.matchAll(importRe)) {
const specifier = match[1] || match[2];
if (specifier.startsWith('.')) {
const resolved = path.resolve(path.dirname(file), specifier);
if (/\.vue(\.js)?$/.test(resolved)) {
violations.push(`${path.relative(esDir, file)} -> ${specifier}`);
} else if (existsSync(resolved) && resolved.endsWith('.js')) {
queue.push(resolved);
}
continue;
}
if (SDK_FORBIDDEN.some((re) => re.test(specifier))) {
violations.push(`${path.relative(esDir, file)} -> ${specifier}`);
}
}
}
if (violations.length) {
failed += 1;
console.error(`✗ sdk 产物引入了禁用依赖:\n ${violations.join('\n ')}`);
} else {
console.log(`✓ sdk 依赖链干净(共 ${seen.size} 个模块)`);
}
};
checkSdkImports();
if (failed) {
console.error(`\n${failed} 个 headless 产物检查失败`);
process.exit(1);
}