mirror of
https://github.com/motajs/template.git
synced 2026-11-03 07:24:30 +08:00
feat(09-22): strip replay decorators in both builds + content list filtering
This commit is contained in:
1 parent
9d291a6f80
commit
6015628d9b
7 files changed
+913
-150
No files matched your search
@@ -0,0 +1,348 @@
|
||||
import { build, Plugin } from 'vite';
|
||||
import { RollupOutput } from 'rollup';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
dataIsolationPlugin,
|
||||
removeReplayDecorators,
|
||||
replayDecoratorPlugin
|
||||
} from '../build-plugin';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
/** 创建独立的临时构建根目录,并登记以便用例结束后统一清理 */
|
||||
async function createRoot() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'mota-data-isolation-'));
|
||||
tempDirs.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 在临时根目录下写入文件,必要时创建父目录,返回写入后的绝对路径 */
|
||||
async function writeRootFile(root: string, name: string, content: string) {
|
||||
const file = join(root, name);
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(file, content, 'utf8');
|
||||
return file;
|
||||
}
|
||||
|
||||
/** 探测构建的配置 */
|
||||
interface IProbeBuildOptions {
|
||||
/** 临时构建根目录 */
|
||||
readonly root: string;
|
||||
/** 入口文件绝对路径 */
|
||||
readonly entry: string;
|
||||
/** 参与构建的插件列表 */
|
||||
readonly plugins: Plugin[];
|
||||
}
|
||||
|
||||
/** 以库模式 ESM 执行一次真实的 vite 构建,返回全部 chunk 代码 */
|
||||
async function buildProbe(options: IProbeBuildOptions) {
|
||||
const result = (await build({
|
||||
configFile: false,
|
||||
root: options.root,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
write: false,
|
||||
minify: false,
|
||||
copyPublicDir: false,
|
||||
lib: {
|
||||
entry: options.entry,
|
||||
formats: ['es'],
|
||||
fileName: 'probe'
|
||||
}
|
||||
},
|
||||
plugins: options.plugins
|
||||
})) as RollupOutput | RollupOutput[];
|
||||
const outputs = Array.isArray(result) ? result : [result];
|
||||
return outputs
|
||||
.flatMap(v => v.output)
|
||||
.filter(v => v.type === 'chunk')
|
||||
.map(v => v.code)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
type TransformHook = (code: string, id: string) => { code: string } | null;
|
||||
|
||||
/** 取出插件 transform 钩子的可调用形式,兼容对象式钩子 */
|
||||
function getTransform(plugin: Plugin): TransformHook {
|
||||
const hook: unknown = plugin.transform;
|
||||
if (typeof hook === 'function') {
|
||||
return hook as TransformHook;
|
||||
}
|
||||
return (hook as { handler: TransformHook }).handler;
|
||||
}
|
||||
|
||||
/** 以数据端库模式 ESM 配置执行一次真实的 vite 构建 */
|
||||
function buildFixture(root: string, entry: string) {
|
||||
return build({
|
||||
configFile: false,
|
||||
root,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
write: false,
|
||||
minify: false,
|
||||
copyPublicDir: false,
|
||||
lib: {
|
||||
entry,
|
||||
formats: ['es'],
|
||||
fileName: 'data'
|
||||
}
|
||||
},
|
||||
plugins: [dataIsolationPlugin()]
|
||||
});
|
||||
}
|
||||
|
||||
/** 执行构建并返回错误信息,构建成功时返回空字符串 */
|
||||
async function getBuildError(root: string, entry: string) {
|
||||
try {
|
||||
await buildFixture(root, entry);
|
||||
} catch (e) {
|
||||
return String(e);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 录像装饰器探测源码:工厂函数本地定义且不导出,两个方法各带一种装饰器并返回唯一字面量 */
|
||||
const PROBE_SOURCE = `
|
||||
function shouldReplay(message: string) {
|
||||
return function (method: unknown) {
|
||||
return method;
|
||||
};
|
||||
}
|
||||
function ignoreReplay(_msg: string) {
|
||||
return function (method: unknown) {
|
||||
return method;
|
||||
};
|
||||
}
|
||||
export class Probe {
|
||||
@shouldReplay('replay probe')
|
||||
replayProbe(): string {
|
||||
return 'mota_replay_probe';
|
||||
}
|
||||
@ignoreReplay('ignore probe')
|
||||
ignoreProbe(): string {
|
||||
return 'mota_ignore_probe';
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
afterEach(async () => {
|
||||
const dirs = tempDirs.splice(0);
|
||||
await Promise.all(
|
||||
dirs.map(dir => rm(dir, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe('dataIsolationPlugin', () => {
|
||||
// 直接引入 vue 说明符的数据端模块必须被插件拒绝
|
||||
it('rejects a render-side specifier', async () => {
|
||||
const root = await createRoot();
|
||||
const entry = await writeRootFile(root, 'entry.ts', "import 'vue';\n");
|
||||
|
||||
const message = await getBuildError(root, entry);
|
||||
|
||||
expect(message).toContain('[mota-data-isolation]');
|
||||
expect(message).toContain('matched: vue');
|
||||
expect(message).toContain(
|
||||
'data-side build must not depend on render-side'
|
||||
);
|
||||
});
|
||||
|
||||
// 相对引入的 src/client 渲染目录必须按解析路径被 moduleParsed 拒绝
|
||||
it('rejects a resolved src/client module', async () => {
|
||||
const root = await createRoot();
|
||||
await writeRootFile(
|
||||
root,
|
||||
'src/client/ui/fake.ts',
|
||||
'export const fake = 1;\n'
|
||||
);
|
||||
const entry = await writeRootFile(
|
||||
root,
|
||||
'entry.ts',
|
||||
"import './src/client/ui/fake';\n"
|
||||
);
|
||||
|
||||
const message = await getBuildError(root, entry);
|
||||
|
||||
expect(message).toContain('[mota-data-isolation]');
|
||||
expect(message).toContain('matched: src/client');
|
||||
});
|
||||
|
||||
// 相对引入的 packages-user/client-* 必须按解析路径被 moduleParsed 拒绝
|
||||
it('rejects a resolved packages-user client package', async () => {
|
||||
const root = await createRoot();
|
||||
await writeRootFile(
|
||||
root,
|
||||
'packages-user/client-modules/fake.ts',
|
||||
'export const fake = 1;\n'
|
||||
);
|
||||
const entry = await writeRootFile(
|
||||
root,
|
||||
'entry.ts',
|
||||
"import './packages-user/client-modules/fake';\n"
|
||||
);
|
||||
|
||||
const message = await getBuildError(root, entry);
|
||||
|
||||
expect(message).toContain('[mota-data-isolation]');
|
||||
expect(message).toContain('matched: packages-user/client-');
|
||||
});
|
||||
|
||||
// 纯数据端依赖必须通过构建并产出 ESM 代码
|
||||
it('passes a pure data-side graph and emits ESM', async () => {
|
||||
const root = await createRoot();
|
||||
await writeRootFile(
|
||||
root,
|
||||
'packages-user/data-common/fake.ts',
|
||||
'export const value = 1;\n'
|
||||
);
|
||||
const entry = await writeRootFile(
|
||||
root,
|
||||
'entry.ts',
|
||||
"export { value } from './packages-user/data-common/fake';\n"
|
||||
);
|
||||
|
||||
const result = (await buildFixture(root, entry)) as
|
||||
| RollupOutput
|
||||
| RollupOutput[];
|
||||
const outputs = Array.isArray(result) ? result : [result];
|
||||
const code = outputs
|
||||
.flatMap(v => v.output)
|
||||
.filter(v => v.type === 'chunk')
|
||||
.map(v => v.code)
|
||||
.join('\n');
|
||||
|
||||
expect(code).toContain('export');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeReplayDecorators', () => {
|
||||
// 方法上方的 @shouldReplay 调用装饰器必须被整段删除,类与方法保持原样
|
||||
it('removes a shouldReplay decorator', () => {
|
||||
const code = [
|
||||
'export class Sample {',
|
||||
" @shouldReplay('should be replayed')",
|
||||
' run(): void {}',
|
||||
'}'
|
||||
].join('\n');
|
||||
|
||||
const output = removeReplayDecorators(code, 'sample.ts');
|
||||
|
||||
expect(output).not.toContain('@shouldReplay');
|
||||
expect(output).toContain('run(): void {}');
|
||||
expect(output).toContain('export class Sample');
|
||||
});
|
||||
|
||||
// 方法上方的 @ignoreReplay 调用装饰器同样必须被整段删除
|
||||
it('removes an ignoreReplay decorator', () => {
|
||||
const code = [
|
||||
'export class Sample {',
|
||||
" @ignoreReplay('async content')",
|
||||
' run(): void {}',
|
||||
'}'
|
||||
].join('\n');
|
||||
|
||||
const output = removeReplayDecorators(code, 'sample.ts');
|
||||
|
||||
expect(output).not.toContain('@ignoreReplay');
|
||||
expect(output).toContain('run(): void {}');
|
||||
});
|
||||
|
||||
// 参数含双引号、逗号与嵌套括号时也必须整段删除,不残留括号或字符串碎片
|
||||
it('removes a decorator with quoted and nested arguments', () => {
|
||||
const code = [
|
||||
'export class Sample {',
|
||||
' @shouldReplay("a (nested) call, with \'quotes\'")',
|
||||
' run(): void {}',
|
||||
'}'
|
||||
].join('\n');
|
||||
|
||||
const output = removeReplayDecorators(code, 'sample.ts');
|
||||
|
||||
expect(output).not.toContain('@shouldReplay');
|
||||
expect(output).not.toContain('nested');
|
||||
expect(output).not.toContain('quotes');
|
||||
expect(output).toContain('run(): void {}');
|
||||
});
|
||||
|
||||
// 字符串字面量中出现的同名文本不得被删除
|
||||
it('keeps the same text inside string literals', () => {
|
||||
const code = [
|
||||
'export const message = "@shouldReplay inside a string";',
|
||||
'export class Sample {',
|
||||
' run(): void {}',
|
||||
'}'
|
||||
].join('\n');
|
||||
|
||||
const output = removeReplayDecorators(code, 'sample.ts');
|
||||
|
||||
expect(output).toBe(code);
|
||||
expect(output).toContain('@shouldReplay inside a string');
|
||||
});
|
||||
|
||||
// 注释中出现的同名文本不得被删除
|
||||
it('keeps the same text inside comments', () => {
|
||||
const code = [
|
||||
'// @shouldReplay is applied here in the real source',
|
||||
'export class Sample {',
|
||||
' run(): void {}',
|
||||
'}'
|
||||
].join('\n');
|
||||
|
||||
const output = removeReplayDecorators(code, 'sample.ts');
|
||||
|
||||
expect(output).toBe(code);
|
||||
expect(output).toContain('@shouldReplay is applied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replayDecoratorPlugin', () => {
|
||||
// 插件必须跳过 node_modules 与非 TypeScript 文件,只改写项目自身的源码
|
||||
it('skips node_modules and non-TypeScript files', async () => {
|
||||
const root = await createRoot();
|
||||
const transform = getTransform(replayDecoratorPlugin());
|
||||
const code = [
|
||||
'export class Sample {',
|
||||
" @shouldReplay('should be replayed')",
|
||||
' run(): void {}',
|
||||
'}'
|
||||
].join('\n');
|
||||
|
||||
expect(
|
||||
transform(code, join(root, 'node_modules', 'dep', 'a.ts'))
|
||||
).toBeNull();
|
||||
expect(transform(code, join(root, 'src', 'a.css'))).toBeNull();
|
||||
|
||||
const result = transform(code, join(root, 'src', 'a.ts'));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.code).not.toContain('@shouldReplay');
|
||||
});
|
||||
|
||||
// 对照构建证明 token 在未处理时确实存在,实验构建证明只删装饰器而未删类与方法
|
||||
it('strips decorators in a real build without dropping class members', async () => {
|
||||
const root = await createRoot();
|
||||
const entry = await writeRootFile(root, 'probe.ts', PROBE_SOURCE);
|
||||
|
||||
const control = await buildProbe({ root, entry, plugins: [] });
|
||||
expect(control).toContain('shouldReplay');
|
||||
expect(control).toContain('ignoreReplay');
|
||||
expect(control).toContain('mota_replay_probe');
|
||||
expect(control).toContain('mota_ignore_probe');
|
||||
expect(control).toContain('replayProbe');
|
||||
expect(control).toContain('ignoreProbe');
|
||||
|
||||
const stripped = await buildProbe({
|
||||
root,
|
||||
entry,
|
||||
plugins: [replayDecoratorPlugin()]
|
||||
});
|
||||
expect(stripped).not.toContain('shouldReplay');
|
||||
expect(stripped).not.toContain('ignoreReplay');
|
||||
expect(stripped).toContain('mota_replay_probe');
|
||||
expect(stripped).toContain('mota_ignore_probe');
|
||||
expect(stripped).toContain('replayProbe');
|
||||
expect(stripped).toContain('ignoreProbe');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { buildContent } from '../build-resource';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
/** 创建独立的临时 fixture 根目录,并登记以便用例结束后统一清理 */
|
||||
async function createRoot() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'mota-content-filter-'));
|
||||
tempDirs.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 在 fixture 的源目录下写入文件,必要时创建父目录 */
|
||||
async function writeSource(
|
||||
root: string,
|
||||
name: string,
|
||||
content: string | Buffer
|
||||
) {
|
||||
const file = join(root, 'src', name);
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(file, content);
|
||||
return file;
|
||||
}
|
||||
|
||||
/** 以空字符集执行一次 content 构建 */
|
||||
function buildFixture(root: string) {
|
||||
return buildContent({
|
||||
sourceDir: join(root, 'src'),
|
||||
outputDir: join(root, 'out'),
|
||||
chars: ''
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取输出目录中的文本文件 */
|
||||
function readOutput(root: string, name: string) {
|
||||
return readFile(join(root, 'out', name), 'utf-8');
|
||||
}
|
||||
|
||||
/** 判断输出目录中是否存在指定文件 */
|
||||
async function outputExists(root: string, name: string) {
|
||||
try {
|
||||
await stat(join(root, 'out', name));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
const dirs = tempDirs.splice(0);
|
||||
await Promise.all(
|
||||
dirs.map(dir => rm(dir, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe('buildContent', () => {
|
||||
// 根目录的 jsonc 必须去掉整行注释并压成纯 JSON,后缀保持不变
|
||||
it('minifies a root jsonc file', async () => {
|
||||
const root = await createRoot();
|
||||
await writeSource(
|
||||
root,
|
||||
'core.jsonc',
|
||||
'// Type: IGameCore\n// --- SYSTEM PREFIX END --- //\n{ "floorId": "MT0" }\n'
|
||||
);
|
||||
|
||||
const stats = await buildFixture(root);
|
||||
const output = await readOutput(root, 'core.jsonc');
|
||||
|
||||
expect(JSON.parse(output)).toEqual({ floorId: 'MT0' });
|
||||
expect(output).not.toContain('//');
|
||||
expect(stats.jsonc).toBe(1);
|
||||
});
|
||||
|
||||
// 根目录即使出现 .list.jsonc 也必须全量输出,根调用显式跳过清单过滤
|
||||
it('keeps every root file even when a root list exists', async () => {
|
||||
const root = await createRoot();
|
||||
await writeSource(root, '.list.jsonc', '["core"]\n');
|
||||
await writeSource(root, 'core.jsonc', '{ "value": 1 }\n');
|
||||
await writeSource(root, 'other.jsonc', '{ "value": 2 }\n');
|
||||
|
||||
await buildFixture(root);
|
||||
|
||||
expect(await outputExists(root, 'core.jsonc')).toBe(true);
|
||||
expect(await outputExists(root, 'other.jsonc')).toBe(true);
|
||||
expect(await outputExists(root, '.list.jsonc')).toBe(true);
|
||||
});
|
||||
|
||||
// defaults 目录即使存在 .list.jsonc 也必须全量输出并压缩
|
||||
it('keeps every defaults file even when a defaults list exists', async () => {
|
||||
const root = await createRoot();
|
||||
await writeSource(root, 'defaults/.list.jsonc', '["env"]\n');
|
||||
await writeSource(
|
||||
root,
|
||||
'defaults/env.jsonc',
|
||||
'// Type: env\n{ "value": 1 }\n'
|
||||
);
|
||||
await writeSource(root, 'defaults/other.jsonc', '{ "value": 2 }\n');
|
||||
|
||||
await buildFixture(root);
|
||||
|
||||
expect(await outputExists(root, 'defaults/env.jsonc')).toBe(true);
|
||||
expect(await outputExists(root, 'defaults/other.jsonc')).toBe(true);
|
||||
expect(await outputExists(root, 'defaults/.list.jsonc')).toBe(true);
|
||||
expect(await readOutput(root, 'defaults/other.jsonc')).not.toContain(
|
||||
'//'
|
||||
);
|
||||
expect(
|
||||
JSON.parse(await readOutput(root, 'defaults/.list.jsonc'))
|
||||
).toEqual(['env']);
|
||||
});
|
||||
|
||||
// maps 清单带注释时先剥离注释,再按清单保留文件并剔除未引用文件
|
||||
it('filters a maps directory by its list', async () => {
|
||||
const root = await createRoot();
|
||||
await writeSource(
|
||||
root,
|
||||
'maps/.list.jsonc',
|
||||
'// Type: string[], without .jsonc suffix\n// --- SYSTEM PREFIX END --- //\n["keep"]\n'
|
||||
);
|
||||
await writeSource(root, 'maps/keep.jsonc', '{ "value": "keep" }\n');
|
||||
await writeSource(root, 'maps/drop.jsonc', '{ "value": "drop" }\n');
|
||||
|
||||
await buildFixture(root);
|
||||
|
||||
expect(await outputExists(root, 'maps/keep.jsonc')).toBe(true);
|
||||
expect(await outputExists(root, 'maps/drop.jsonc')).toBe(false);
|
||||
expect(await outputExists(root, 'maps/.list.jsonc')).toBe(true);
|
||||
const list = await readOutput(root, 'maps/.list.jsonc');
|
||||
expect(list).not.toContain('//');
|
||||
expect(JSON.parse(list)).toEqual(['keep']);
|
||||
});
|
||||
|
||||
// texture 清单是对象形态且使用完整文件名,命中文件按字节原样拷贝
|
||||
it('filters a texture directory by object list and full file name', async () => {
|
||||
const root = await createRoot();
|
||||
await writeSource(
|
||||
root,
|
||||
'texture/.list.jsonc',
|
||||
'{"textures":[{"type":"grid","source":"a.png"}]}\n'
|
||||
);
|
||||
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x01, 0x02]);
|
||||
await writeSource(root, 'texture/a.png', bytes);
|
||||
await writeSource(root, 'texture/b.png', Buffer.from([0x03]));
|
||||
|
||||
await buildFixture(root);
|
||||
|
||||
expect(await outputExists(root, 'texture/a.png')).toBe(true);
|
||||
expect(await outputExists(root, 'texture/b.png')).toBe(false);
|
||||
const output = await readFile(join(root, 'out', 'texture', 'a.png'));
|
||||
expect(Buffer.compare(output, bytes)).toBe(0);
|
||||
});
|
||||
|
||||
// font 清单按完整文件名保留,未引用文件被剔除
|
||||
it('filters a font directory by its list', async () => {
|
||||
const root = await createRoot();
|
||||
await writeSource(root, 'font/.list.jsonc', '["ok.fake"]\n');
|
||||
await writeSource(root, 'font/ok.fake', 'ok');
|
||||
await writeSource(root, 'font/skip.fake', 'skip');
|
||||
|
||||
await buildFixture(root);
|
||||
|
||||
expect(await outputExists(root, 'font/ok.fake')).toBe(true);
|
||||
expect(await outputExists(root, 'font/skip.fake')).toBe(false);
|
||||
});
|
||||
|
||||
// 没有清单的非 defaults 子文件夹必须全量输出
|
||||
it('keeps every file in a sub directory without a list', async () => {
|
||||
const root = await createRoot();
|
||||
await writeSource(
|
||||
root,
|
||||
'extra/free.jsonc',
|
||||
'// Type: free\n{ "value": 1 }\n'
|
||||
);
|
||||
await writeSource(root, 'extra/free.txt', 'free');
|
||||
|
||||
await buildFixture(root);
|
||||
|
||||
expect(await outputExists(root, 'extra/free.txt')).toBe(true);
|
||||
expect(await readOutput(root, 'extra/free.jsonc')).not.toContain('//');
|
||||
});
|
||||
|
||||
// 根目录的非 jsonc 文件按原样拷贝
|
||||
it('copies a root non-jsonc file verbatim', async () => {
|
||||
const root = await createRoot();
|
||||
const source = 'export const item = 1;\n';
|
||||
await writeSource(root, 'item.ts', source);
|
||||
|
||||
await buildFixture(root);
|
||||
|
||||
expect(await readOutput(root, 'item.ts')).toBe(source);
|
||||
});
|
||||
|
||||
// 统计结果必须与实际输出文件数一致
|
||||
it('reports stats matching the emitted files', async () => {
|
||||
const root = await createRoot();
|
||||
await writeSource(root, 'core.jsonc', '{ "value": 1 }\n');
|
||||
await writeSource(root, 'maps/.list.jsonc', '["keep"]\n');
|
||||
await writeSource(root, 'maps/keep.jsonc', '{ "value": 2 }\n');
|
||||
await writeSource(root, 'maps/drop.jsonc', '{ "value": 3 }\n');
|
||||
|
||||
const stats = await buildFixture(root);
|
||||
|
||||
expect(stats.files).toBe(3);
|
||||
expect(stats.jsonc).toBe(3);
|
||||
expect(stats.fonts).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import legacy from '@vitejs/plugin-legacy';
|
||||
import { resolve } from 'path';
|
||||
import { copy, emptyDir, ensureDir } from 'fs-extra';
|
||||
import { readdir, readFile, stat } from 'fs/promises';
|
||||
import { dataIsolationPlugin } from './build-plugin';
|
||||
import { dataIsolationPlugin, replayDecoratorPlugin } from './build-plugin';
|
||||
import { buildContent } from './build-resource';
|
||||
import { formatSize } from './utils';
|
||||
|
||||
@@ -75,6 +75,7 @@ async function buildClient(outDir: string) {
|
||||
);
|
||||
const resolved = mergeConfig(config?.config ?? {}, {
|
||||
plugins: [
|
||||
replayDecoratorPlugin(),
|
||||
legacy({
|
||||
targets: [
|
||||
'Chrome >= 56',
|
||||
@@ -131,7 +132,7 @@ async function buildData(outDir: string, entry: string) {
|
||||
configFile
|
||||
);
|
||||
const resolved = mergeConfig(config?.config ?? {}, {
|
||||
plugins: [dataIsolationPlugin()],
|
||||
plugins: [dataIsolationPlugin(), replayDecoratorPlugin()],
|
||||
build: {
|
||||
outDir,
|
||||
copyPublicDir: false,
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { build } from 'vite';
|
||||
import { RollupOutput } from 'rollup';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { dataIsolationPlugin } from './build-plugin';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
/** 创建独立的临时构建根目录,并登记以便用例结束后统一清理 */
|
||||
async function createRoot() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'mota-data-isolation-'));
|
||||
tempDirs.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 在临时根目录下写入文件,必要时创建父目录,返回写入后的绝对路径 */
|
||||
async function writeRootFile(root: string, name: string, content: string) {
|
||||
const file = join(root, name);
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(file, content, 'utf8');
|
||||
return file;
|
||||
}
|
||||
|
||||
/** 以数据端库模式 ESM 配置执行一次真实的 vite 构建 */
|
||||
function buildFixture(root: string, entry: string) {
|
||||
return build({
|
||||
configFile: false,
|
||||
root,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
write: false,
|
||||
minify: false,
|
||||
copyPublicDir: false,
|
||||
lib: {
|
||||
entry,
|
||||
formats: ['es'],
|
||||
fileName: 'data'
|
||||
}
|
||||
},
|
||||
plugins: [dataIsolationPlugin()]
|
||||
});
|
||||
}
|
||||
|
||||
/** 执行构建并返回错误信息,构建成功时返回空字符串 */
|
||||
async function getBuildError(root: string, entry: string) {
|
||||
try {
|
||||
await buildFixture(root, entry);
|
||||
} catch (e) {
|
||||
return String(e);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
const dirs = tempDirs.splice(0);
|
||||
await Promise.all(
|
||||
dirs.map(dir => rm(dir, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe('dataIsolationPlugin', () => {
|
||||
// 直接引入 vue 说明符的数据端模块必须被插件拒绝
|
||||
it('rejects a render-side specifier', async () => {
|
||||
const root = await createRoot();
|
||||
const entry = await writeRootFile(root, 'entry.ts', "import 'vue';\n");
|
||||
|
||||
const message = await getBuildError(root, entry);
|
||||
|
||||
expect(message).toContain('[mota-data-isolation]');
|
||||
expect(message).toContain('matched: vue');
|
||||
expect(message).toContain(
|
||||
'data-side build must not depend on render-side'
|
||||
);
|
||||
});
|
||||
|
||||
// 相对引入的 src/client 渲染目录必须按解析路径被 moduleParsed 拒绝
|
||||
it('rejects a resolved src/client module', async () => {
|
||||
const root = await createRoot();
|
||||
await writeRootFile(
|
||||
root,
|
||||
'src/client/ui/fake.ts',
|
||||
'export const fake = 1;\n'
|
||||
);
|
||||
const entry = await writeRootFile(
|
||||
root,
|
||||
'entry.ts',
|
||||
"import './src/client/ui/fake';\n"
|
||||
);
|
||||
|
||||
const message = await getBuildError(root, entry);
|
||||
|
||||
expect(message).toContain('[mota-data-isolation]');
|
||||
expect(message).toContain('matched: src/client');
|
||||
});
|
||||
|
||||
// 相对引入的 packages-user/client-* 必须按解析路径被 moduleParsed 拒绝
|
||||
it('rejects a resolved packages-user client package', async () => {
|
||||
const root = await createRoot();
|
||||
await writeRootFile(
|
||||
root,
|
||||
'packages-user/client-modules/fake.ts',
|
||||
'export const fake = 1;\n'
|
||||
);
|
||||
const entry = await writeRootFile(
|
||||
root,
|
||||
'entry.ts',
|
||||
"import './packages-user/client-modules/fake';\n"
|
||||
);
|
||||
|
||||
const message = await getBuildError(root, entry);
|
||||
|
||||
expect(message).toContain('[mota-data-isolation]');
|
||||
expect(message).toContain('matched: packages-user/client-');
|
||||
});
|
||||
|
||||
// 纯数据端依赖必须通过构建并产出 ESM 代码
|
||||
it('passes a pure data-side graph and emits ESM', async () => {
|
||||
const root = await createRoot();
|
||||
await writeRootFile(
|
||||
root,
|
||||
'packages-user/data-common/fake.ts',
|
||||
'export const value = 1;\n'
|
||||
);
|
||||
const entry = await writeRootFile(
|
||||
root,
|
||||
'entry.ts',
|
||||
"export { value } from './packages-user/data-common/fake';\n"
|
||||
);
|
||||
|
||||
const result = (await buildFixture(root, entry)) as
|
||||
| RollupOutput
|
||||
| RollupOutput[];
|
||||
const outputs = Array.isArray(result) ? result : [result];
|
||||
const code = outputs
|
||||
.flatMap(v => v.output)
|
||||
.filter(v => v.type === 'chunk')
|
||||
.map(v => v.code)
|
||||
.join('\n');
|
||||
|
||||
expect(code).toContain('export');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Plugin } from 'vite';
|
||||
import { ModuleInfo } from 'rollup';
|
||||
import ts from 'typescript';
|
||||
|
||||
/** 数据端构建中禁止出现的渲染端模块说明符,第一项为匹配规则,第二项为报错时的命中标记 */
|
||||
const SPECIFIER_BANS: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
@@ -76,3 +77,91 @@ export function dataIsolationPlugin(): Plugin {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建时需要删除的录像装饰器名称 */
|
||||
const REPLAY_DECORATORS: ReadonlyArray<string> = [
|
||||
'shouldReplay',
|
||||
'ignoreReplay'
|
||||
];
|
||||
/** 需要处理录像装饰器的源文件后缀 */
|
||||
const SCRIPT_EXTENSIONS: ReadonlyArray<string> = ['.ts', '.tsx'];
|
||||
/** 判定第三方依赖路径的 node_modules 片段 */
|
||||
const NODE_MODULES_SEGMENT = /[/\\]node_modules[/\\]/;
|
||||
|
||||
/**
|
||||
* 删除源码中所有录像装饰器的应用,只按装饰器节点的文本范围切片,不会误删字符串与注释。
|
||||
* @param code 源码文本
|
||||
* @param fileName 源码文件名,用于确定脚本种类
|
||||
*/
|
||||
export function removeReplayDecorators(code: string, fileName: string): string {
|
||||
const kind = fileName.endsWith('.tsx')
|
||||
? ts.ScriptKind.TSX
|
||||
: ts.ScriptKind.TS;
|
||||
const sourceFile = ts.createSourceFile(
|
||||
fileName,
|
||||
code,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
kind
|
||||
);
|
||||
const ranges: Array<[number, number]> = [];
|
||||
const stack: ts.Node[] = [sourceFile];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (ts.canHaveDecorators(node)) {
|
||||
const decorators = ts.getDecorators(node);
|
||||
if (decorators) {
|
||||
for (const decorator of decorators) {
|
||||
const expression = decorator.expression;
|
||||
const target = ts.isCallExpression(expression)
|
||||
? expression.expression
|
||||
: expression;
|
||||
if (
|
||||
ts.isIdentifier(target) &&
|
||||
REPLAY_DECORATORS.includes(target.text)
|
||||
) {
|
||||
ranges.push([
|
||||
decorator.getStart(sourceFile),
|
||||
decorator.getEnd()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, child => {
|
||||
stack.push(child);
|
||||
});
|
||||
}
|
||||
|
||||
if (ranges.length === 0) return code;
|
||||
|
||||
let result = code;
|
||||
ranges.sort((a, b) => b[0] - a[0]);
|
||||
for (const [start, end] of ranges) {
|
||||
result = result.slice(0, start) + result.slice(end);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建删除录像装饰器的构建插件,仅处理项目自身的 TypeScript 源码,跳过第三方依赖。
|
||||
*/
|
||||
export function replayDecoratorPlugin(): Plugin {
|
||||
return {
|
||||
name: 'mota-replay-decorator',
|
||||
enforce: 'pre',
|
||||
apply: 'build',
|
||||
transform(code, id) {
|
||||
const fileName = id.split('?')[0];
|
||||
if (NODE_MODULES_SEGMENT.test(fileName)) return null;
|
||||
const matched = SCRIPT_EXTENSIONS.some(extension =>
|
||||
fileName.endsWith(extension)
|
||||
);
|
||||
if (!matched) return null;
|
||||
const result = removeReplayDecorators(code, fileName);
|
||||
if (result === code) return null;
|
||||
return { code: result, map: null };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import Fontmin from 'fontmin';
|
||||
import { copy } from 'fs-extra';
|
||||
import { mkdir, readdir, readFile, writeFile } from 'fs/promises';
|
||||
import { dirname, extname, join } from 'path';
|
||||
import { basename, dirname, extname, join } from 'path';
|
||||
|
||||
/** 需要经 fontmin 做字形子集化的字体扩展名 */
|
||||
const FONT_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2'];
|
||||
|
||||
/** 子目录中声明保留素材的清单文件名,清单自身始终会压缩输出 */
|
||||
const LIST_FILE_NAME = '.list.jsonc';
|
||||
|
||||
/** 永远全量输出、不参与清单过滤的素材目录名 */
|
||||
const DEFAULTS_DIRECTORY = 'defaults';
|
||||
|
||||
export interface IContentBuildOptions {
|
||||
/** content 素材的源目录 */
|
||||
readonly sourceDir: string;
|
||||
@@ -42,6 +48,48 @@ export function minifyJSONC(text: string): string {
|
||||
return JSON.stringify(JSON.parse(body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取清单文件并收集其中声明的全部文件标识,清单中的注释会先被去除。
|
||||
* @param listFile 清单文件路径
|
||||
*/
|
||||
async function readListNames(listFile: string): Promise<Set<string>> {
|
||||
const text = await readFile(listFile, 'utf-8');
|
||||
const names = new Set<string>();
|
||||
collectListedNames(JSON.parse(minifyJSONC(text)), names);
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归收集清单值中的全部字符串标识,数字、布尔与空值会被忽略。
|
||||
* @param value 清单中的任意值
|
||||
* @param output 收集结果
|
||||
*/
|
||||
function collectListedNames(value: unknown, output: Set<string>): void {
|
||||
if (typeof value === 'string') {
|
||||
output.add(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectListedNames(item, output);
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
for (const item of Object.values(value)) {
|
||||
collectListedNames(item, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件名是否被清单保留,只支持完整文件名与去扩展名主文件名两种精确匹配。
|
||||
* @param name 待判断的文件名
|
||||
* @param names 清单声明的名称集合
|
||||
*/
|
||||
function isListedFile(name: string, names: Set<string>): boolean {
|
||||
if (names.has(name)) return true;
|
||||
const extension = extname(name);
|
||||
const stem = name.slice(0, name.length - extension.length);
|
||||
return names.has(stem);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单个字体文件按给定字符集做字形子集化,输出到目标目录并保留原文件名。
|
||||
* @param source 字体源文件
|
||||
@@ -65,17 +113,34 @@ async function processDirectory(
|
||||
sourceDir: string,
|
||||
outputDir: string,
|
||||
chars: string,
|
||||
counter: IContentBuildCounter
|
||||
counter: IContentBuildCounter,
|
||||
isRoot: boolean = false
|
||||
) {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
const entries = await readdir(sourceDir, { withFileTypes: true });
|
||||
const isDefaults = basename(sourceDir) === DEFAULTS_DIRECTORY;
|
||||
const filterEnabled = !isRoot && !isDefaults;
|
||||
const listEntry = filterEnabled
|
||||
? entries.find(entry => entry.isFile() && entry.name === LIST_FILE_NAME)
|
||||
: undefined;
|
||||
const names = listEntry
|
||||
? await readListNames(join(sourceDir, listEntry.name))
|
||||
: null;
|
||||
|
||||
for (const entry of entries) {
|
||||
const source = join(sourceDir, entry.name);
|
||||
const target = join(outputDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await processDirectory(source, target, chars, counter);
|
||||
await processDirectory(source, target, chars, counter, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
names &&
|
||||
entry.name !== LIST_FILE_NAME &&
|
||||
!isListedFile(entry.name, names)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -106,7 +171,8 @@ export async function buildContent(
|
||||
options.sourceDir,
|
||||
options.outputDir,
|
||||
options.chars,
|
||||
counter
|
||||
counter,
|
||||
true
|
||||
);
|
||||
|
||||
if (counter.fonts === 0) {
|
||||
|
||||
Reference in new issue
Block a user