clickgo 6.4.2 → 6.5.0
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/dist/app/demo.cga +0 -0
- package/dist/app/task.cga +0 -0
- package/dist/control/arteditor.cgc +0 -0
- package/dist/control/captcha.cgc +0 -0
- package/dist/control/common.cgc +0 -0
- package/dist/control/desc.cgc +0 -0
- package/dist/control/drawer.cgc +0 -0
- package/dist/control/form.cgc +0 -0
- package/dist/control/iconview.cgc +0 -0
- package/dist/control/jodit.cgc +0 -0
- package/dist/control/map.cgc +0 -0
- package/dist/control/monaco.cgc +0 -0
- package/dist/control/mpegts.cgc +0 -0
- package/dist/control/nav.cgc +0 -0
- package/dist/control/page.cgc +0 -0
- package/dist/control/property.cgc +0 -0
- package/dist/control/swiper.cgc +0 -0
- package/dist/control/table.cgc +0 -0
- package/dist/control/task.cgc +0 -0
- package/dist/control/tuieditor.cgc +0 -0
- package/dist/control/xterm.cgc +0 -0
- package/dist/global.css +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/control.d.ts +4 -0
- package/dist/lib/dom.d.ts +30 -0
- package/dist/lib/form.d.ts +9 -0
- package/dist/lib/tool.d.ts +12 -0
- package/dist/theme/admin.cgt +0 -0
- package/dist/theme/cyber.cgt +0 -0
- package/dist/theme/dark.cgt +0 -0
- package/dist/theme/light.cgt +0 -0
- package/dist/theme/modern-light.cgt +0 -0
- package/dist/theme/modern.cgt +0 -0
- package/doc/clickgo-rag.md +1346 -951
- package/package.json +5 -2
- package/test/locale-integrity.mjs +189 -0
- package/test/rtl-archive.mjs +416 -0
- package/test/rtl-direction.mjs +193 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clickgo",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.5.0",
|
|
4
4
|
"description": "Background interface, software interface, mobile phone APP interface operation library.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"types": "./dist/clickgo.d.ts",
|
|
15
15
|
"scripts": {
|
|
16
16
|
"sass": "sass --watch dist/:dist/ --style compressed --no-source-map",
|
|
17
|
-
"check": "tsc --noEmit --pretty false && eslint \"dist/**/*.ts\" --format stylish",
|
|
17
|
+
"check": "tsc --noEmit --pretty false && eslint \"dist/**/*.ts\" --format stylish && npm run test:locale",
|
|
18
|
+
"test:archive": "node test/rtl-archive.mjs",
|
|
19
|
+
"test:locale": "node --experimental-vm-modules test/locale-integrity.mjs && node --experimental-vm-modules test/rtl-direction.mjs",
|
|
18
20
|
"native": "electron ./dist/test/native",
|
|
19
21
|
"native2": "electron ./dist/test/native/index2",
|
|
20
22
|
"native3": "electron ./dist/test/native/index3",
|
|
@@ -33,6 +35,7 @@
|
|
|
33
35
|
"electron": "^44.3.0",
|
|
34
36
|
"fabric": "^7.2.0",
|
|
35
37
|
"javascript-obfuscator": "^5.7.0",
|
|
38
|
+
"jsdom": "^26.1.0",
|
|
36
39
|
"jszip": "^3.10.1",
|
|
37
40
|
"mpegts.js": "^1.8.0",
|
|
38
41
|
"rollup": "^4.60.1",
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Run: node --experimental-vm-modules test/locale-integrity.mjs
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { access, readdir, readFile } from 'node:fs/promises';
|
|
4
|
+
import { SourceTextModule } from 'node:vm';
|
|
5
|
+
import { JSDOM } from 'jsdom';
|
|
6
|
+
import ts from 'typescript';
|
|
7
|
+
|
|
8
|
+
/** --- ClickGo 的固定内置语言顺序 --- */
|
|
9
|
+
const codes = [
|
|
10
|
+
'sc', 'tc', 'ja', 'ko', 'th', 'vi', 'ar', 'id',
|
|
11
|
+
'en', 'es', 'de', 'fr', 'pt', 'ru', 'it', 'tr',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/** --- 仍使用问号占位符、会在运行时被替换的语言键 --- */
|
|
15
|
+
const placeholderKeys = new Set([
|
|
16
|
+
'dist/lib/fs.ts:apply-unmount',
|
|
17
|
+
'dist/lib/storage.ts:sure-clear',
|
|
18
|
+
'dist/sources/control/page/code.ts:total-of',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* --- 获取对象字面量的静态属性名 ---
|
|
23
|
+
* @param property TypeScript 属性节点
|
|
24
|
+
* @returns 属性名;动态属性返回空字符串
|
|
25
|
+
*/
|
|
26
|
+
function getPropertyName(property) {
|
|
27
|
+
if (!ts.isPropertyAssignment(property) || !property.name) {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
if (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) || ts.isNumericLiteral(property.name)) {
|
|
31
|
+
return property.name.text;
|
|
32
|
+
}
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* --- 从对象字面量取静态属性 ---
|
|
38
|
+
* @param object 对象字面量
|
|
39
|
+
* @param key 属性名
|
|
40
|
+
* @returns 属性初始化节点
|
|
41
|
+
*/
|
|
42
|
+
function getProperty(object, key) {
|
|
43
|
+
return object.properties.find((property) => getPropertyName(property) === key)?.initializer;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* --- 断言翻译值是非空静态字符串 ---
|
|
48
|
+
* @param value 翻译的 AST 节点
|
|
49
|
+
* @param message 失败信息
|
|
50
|
+
*/
|
|
51
|
+
function assertText(value, message) {
|
|
52
|
+
const isText = ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value);
|
|
53
|
+
assert.equal(isText, true, `${message} must be a static string literal`);
|
|
54
|
+
assert.notEqual(value.text.trim(), '', `${message} must not be empty`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* --- 从 TypeScript 源码收集含英文条目的语言表 ---
|
|
59
|
+
* @param path TypeScript 文件路径
|
|
60
|
+
* @returns 语言表及其行号
|
|
61
|
+
*/
|
|
62
|
+
async function collectLocaleTables(path) {
|
|
63
|
+
const source = ts.createSourceFile(path, await readFile(path, 'utf8'), ts.ScriptTarget.Latest, true);
|
|
64
|
+
const tables = [];
|
|
65
|
+
const visit = (node) => {
|
|
66
|
+
if (ts.isObjectLiteralExpression(node) && getProperty(node, 'en')) {
|
|
67
|
+
const keys = node.properties.map(getPropertyName);
|
|
68
|
+
if (codes.every((code) => keys.includes(code))) {
|
|
69
|
+
tables.push({
|
|
70
|
+
'node': node,
|
|
71
|
+
'line': source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
ts.forEachChild(node, visit);
|
|
76
|
+
};
|
|
77
|
+
visit(source);
|
|
78
|
+
return tables;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>');
|
|
82
|
+
for (const name of ['window', 'document', 'navigator']) {
|
|
83
|
+
Object.defineProperty(globalThis, name, {
|
|
84
|
+
'configurable': true,
|
|
85
|
+
'value': dom.window[name],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The language registry is executed from TypeScript source, not generated JavaScript.
|
|
90
|
+
const toolSource = await readFile(new URL('../dist/lib/tool.ts', import.meta.url), 'utf8');
|
|
91
|
+
const toolModule = new SourceTextModule(ts.transpileModule(toolSource, {
|
|
92
|
+
'compilerOptions': {
|
|
93
|
+
'target': ts.ScriptTarget.ES2022,
|
|
94
|
+
'module': ts.ModuleKind.ESNext,
|
|
95
|
+
},
|
|
96
|
+
}).outputText);
|
|
97
|
+
await toolModule.link(() => {
|
|
98
|
+
throw new Error('tool.ts must not import runtime modules');
|
|
99
|
+
});
|
|
100
|
+
await toolModule.evaluate();
|
|
101
|
+
|
|
102
|
+
const { lang } = toolModule.namespace;
|
|
103
|
+
assert.deepEqual(lang.codes, codes, 'language codes must retain all 16 supported locales');
|
|
104
|
+
assert.equal(lang.names.length, codes.length, 'every locale must have a display name');
|
|
105
|
+
assert.equal(new Set(lang.names).size, codes.length, 'language display names must be unique');
|
|
106
|
+
assert.deepEqual(Object.keys(lang.tags).sort(), [...codes].sort(), 'every locale must have an HTML lang tag');
|
|
107
|
+
for (const code of codes) {
|
|
108
|
+
assert.notEqual(lang.names[lang.codes.indexOf(code)].trim(), '', `${code} must have a non-empty display name`);
|
|
109
|
+
assert.notEqual(lang.getTag(code).trim(), '', `${code} must resolve to a non-empty HTML lang tag`);
|
|
110
|
+
assert.equal(lang.getDirection(code), code === 'ar' ? 'rtl' : 'ltr', `${code} must resolve to its writing direction`);
|
|
111
|
+
}
|
|
112
|
+
assert.equal(lang.getDirection('ar-EG'), 'rtl', 'Arabic HTML tags must resolve to RTL');
|
|
113
|
+
assert.equal(lang.getDirection('en-US'), 'ltr', 'non-Arabic HTML tags must resolve to LTR');
|
|
114
|
+
|
|
115
|
+
const controlRoot = new URL('../dist/sources/control/', import.meta.url);
|
|
116
|
+
const controlNames = (await readdir(controlRoot, { 'withFileTypes': true }))
|
|
117
|
+
.filter((item) => item.isDirectory())
|
|
118
|
+
.map((item) => item.name)
|
|
119
|
+
.sort();
|
|
120
|
+
const controlSources = [];
|
|
121
|
+
for (const name of controlNames) {
|
|
122
|
+
const path = `dist/sources/control/${name}/code.ts`;
|
|
123
|
+
try {
|
|
124
|
+
await access(path);
|
|
125
|
+
controlSources.push(path);
|
|
126
|
+
}
|
|
127
|
+
catch {}
|
|
128
|
+
}
|
|
129
|
+
const localeSources = [
|
|
130
|
+
'dist/lib/form.ts',
|
|
131
|
+
'dist/lib/fs.ts',
|
|
132
|
+
'dist/lib/storage.ts',
|
|
133
|
+
'dist/lib/task.ts',
|
|
134
|
+
...controlSources,
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
const tables = [];
|
|
138
|
+
for (const path of localeSources) {
|
|
139
|
+
for (const table of await collectLocaleTables(path)) {
|
|
140
|
+
tables.push({ ...table, path });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
assert.equal(tables.length, 24, 'all 24 built-in TypeScript locale tables must be present');
|
|
144
|
+
for (const { node, path, line } of tables) {
|
|
145
|
+
const tableCodes = node.properties.map(getPropertyName);
|
|
146
|
+
assert.deepEqual([...tableCodes].sort(), [...codes].sort(), `${path}:${line} must contain exactly 16 locale entries`);
|
|
147
|
+
const english = getProperty(node, 'en');
|
|
148
|
+
assert.equal(ts.isObjectLiteralExpression(english), true, `${path}:${line} English locale must be an object`);
|
|
149
|
+
const keys = english.properties.map(getPropertyName);
|
|
150
|
+
assert.equal(new Set(keys).size, keys.length, `${path}:${line} English locale must not repeat keys`);
|
|
151
|
+
for (const code of codes) {
|
|
152
|
+
const localized = getProperty(node, code);
|
|
153
|
+
assert.equal(ts.isObjectLiteralExpression(localized), true, `${path}:${line} ${code} locale must be an object`);
|
|
154
|
+
const localizedKeys = localized.properties.map(getPropertyName);
|
|
155
|
+
assert.deepEqual([...localizedKeys].sort(), [...keys].sort(), `${path}:${line} ${code} locale keys must match English`);
|
|
156
|
+
for (const key of keys) {
|
|
157
|
+
const localizedText = getProperty(localized, key);
|
|
158
|
+
assertText(localizedText, `${path}:${line} ${code}.${key}`);
|
|
159
|
+
if (placeholderKeys.has(`${path}:${key}`)) {
|
|
160
|
+
assert.equal(localizedText.text.includes('?'), true, `${path}:${line} ${code}.${key} must retain its ? placeholder`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const taskConfig = JSON.parse(await readFile(new URL('../dist/app/task/config.json', import.meta.url), 'utf8'));
|
|
167
|
+
assert.deepEqual(Object.values(taskConfig.locales), codes, 'task app config must load all 16 locales in registry order');
|
|
168
|
+
for (const code of codes) {
|
|
169
|
+
assert.equal(taskConfig.locales[`/package/locale/${code}`], code, `task app must map /package/locale/${code}`);
|
|
170
|
+
}
|
|
171
|
+
const englishTaskLocale = JSON.parse(await readFile(new URL('../dist/app/task/locale/en.json', import.meta.url), 'utf8'));
|
|
172
|
+
const taskKeys = Object.keys(englishTaskLocale).sort();
|
|
173
|
+
for (const code of codes) {
|
|
174
|
+
const locale = JSON.parse(await readFile(new URL(`../dist/app/task/locale/${code}.json`, import.meta.url), 'utf8'));
|
|
175
|
+
assert.deepEqual(Object.keys(locale).sort(), taskKeys, `task app ${code}.json keys must match en.json`);
|
|
176
|
+
for (const [key, value] of Object.entries(locale)) {
|
|
177
|
+
assert.equal(typeof value, 'string', `task app ${code}.json ${key} must be a string`);
|
|
178
|
+
assert.notEqual(value.trim(), '', `task app ${code}.json ${key} must not be empty`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const demoTaskLayout = await readFile(new URL('../dist/app/demo/form/method/task/task.xml', import.meta.url), 'utf8');
|
|
183
|
+
const demoLocaleData = demoTaskLayout.match(/<label>Change global locale lang:<\/label>\s*<layout[^>]*>\s*<select[^>]*:data="\[([^"]+)\]"/)?.[1];
|
|
184
|
+
assert.ok(demoLocaleData, 'demo Library task form must retain the global locale selector');
|
|
185
|
+
const demoLocaleCodes = [...demoLocaleData.matchAll(/'([^']+)'/g)].map((match) => match[1]);
|
|
186
|
+
assert.deepEqual(demoLocaleCodes, codes, 'demo global locale selector must expose all 16 locales in registry order');
|
|
187
|
+
|
|
188
|
+
dom.window.close();
|
|
189
|
+
console.log('Locale registry, 24 TypeScript tables, 16 task app locale JSON files and the Demo selector are complete.');
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// Run after TypeScript/Sass compilation and `node dist/pack.js`: node test/rtl-archive.mjs
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createDecipheriv, createHash } from 'node:crypto';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import CleanCSS from 'clean-css';
|
|
6
|
+
import JSZip from 'jszip';
|
|
7
|
+
import ts from 'typescript';
|
|
8
|
+
|
|
9
|
+
/** --- ClickGo 的固定内置语言顺序 --- */
|
|
10
|
+
const codes = [
|
|
11
|
+
'sc', 'tc', 'ja', 'ko', 'th', 'vi', 'ar', 'id',
|
|
12
|
+
'en', 'es', 'de', 'fr', 'pt', 'ru', 'it', 'tr',
|
|
13
|
+
];
|
|
14
|
+
/** --- 包含 localeData 的全部内置控件 --- */
|
|
15
|
+
const localeControls = [
|
|
16
|
+
'arteditor', 'calendar', 'captcha', 'date', 'datepanel', 'daterange', 'empty', 'jodit', 'label',
|
|
17
|
+
'levelselect', 'map', 'monaco', 'number', 'page', 'palette', 'property', 'select', 'text', 'tuieditor',
|
|
18
|
+
];
|
|
19
|
+
/** --- 合并到 common.cgc 的多语言控件 --- */
|
|
20
|
+
const commonControls = new Set([
|
|
21
|
+
'calendar', 'date', 'datepanel', 'daterange', 'empty', 'label', 'levelselect', 'number', 'palette',
|
|
22
|
+
'select', 'text',
|
|
23
|
+
]);
|
|
24
|
+
/** --- 六套内置主题 --- */
|
|
25
|
+
const themes = ['admin', 'cyber', 'dark', 'light', 'modern', 'modern-light'];
|
|
26
|
+
/** --- CGA 格式常量,与编译器的公开包格式保持一致 --- */
|
|
27
|
+
const cgaHeaderLength = 106;
|
|
28
|
+
const cgaKeyContext = Buffer.from('ClickGo/Application/Package');
|
|
29
|
+
/** --- 已解压的 ZIP 归档缓存 --- */
|
|
30
|
+
const zipCache = new Map();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* --- 生成归档不存在或过期时的统一错误信息 ---
|
|
34
|
+
* @param archive 归档路径
|
|
35
|
+
* @param detail 失败原因
|
|
36
|
+
* @returns 可操作的错误信息
|
|
37
|
+
*/
|
|
38
|
+
function archiveError(archive, detail) {
|
|
39
|
+
return `${archive} is missing or stale: ${detail}. Build TypeScript and Sass, run node dist/pack.js, then rerun this test.`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* --- 读取项目文件 ---
|
|
44
|
+
* @param path 相对项目根目录的路径
|
|
45
|
+
* @returns 文件内容
|
|
46
|
+
*/
|
|
47
|
+
async function readProjectFile(path) {
|
|
48
|
+
return await readFile(new URL(`../${path}`, import.meta.url));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* --- 读取必要归档,并把不存在转换为清晰诊断 ---
|
|
53
|
+
* @param path 归档路径
|
|
54
|
+
* @returns 归档内容
|
|
55
|
+
*/
|
|
56
|
+
async function readArchive(path) {
|
|
57
|
+
try {
|
|
58
|
+
return await readProjectFile(path);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error?.code === 'ENOENT') {
|
|
62
|
+
throw new Error(archiveError(path, 'the archive file does not exist'));
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* --- 加载 cgc/cgt ZIP 归档 ---
|
|
70
|
+
* @param path 归档路径
|
|
71
|
+
* @returns JSZip 实例
|
|
72
|
+
*/
|
|
73
|
+
async function loadZipArchive(path) {
|
|
74
|
+
if (zipCache.has(path)) {
|
|
75
|
+
return zipCache.get(path);
|
|
76
|
+
}
|
|
77
|
+
let zip;
|
|
78
|
+
try {
|
|
79
|
+
zip = await JSZip.loadAsync(await readArchive(path));
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
throw new Error(archiveError(path, `the archive cannot be decoded (${error.message})`));
|
|
83
|
+
}
|
|
84
|
+
zipCache.set(path, zip);
|
|
85
|
+
return zip;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* --- 读取 cgc/cgt 内文件 ---
|
|
90
|
+
* @param archive 归档路径
|
|
91
|
+
* @param entry 包内路径
|
|
92
|
+
* @returns 文件内容
|
|
93
|
+
*/
|
|
94
|
+
async function readZipText(archive, entry) {
|
|
95
|
+
const zip = await loadZipArchive(archive);
|
|
96
|
+
const file = zip.file(entry);
|
|
97
|
+
assert.ok(file, archiveError(archive, `required entry ${entry} is absent`));
|
|
98
|
+
return await file.async('string');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* --- 恢复 CGA 文件头中打散的密钥种子 ---
|
|
103
|
+
* @param maskedSeed 打散后的种子
|
|
104
|
+
* @param salt 随机盐
|
|
105
|
+
* @param packageId 包 ID
|
|
106
|
+
* @returns 原始密钥种子
|
|
107
|
+
*/
|
|
108
|
+
function unmaskCgaSeed(maskedSeed, salt, packageId) {
|
|
109
|
+
const seed = Buffer.alloc(maskedSeed.length);
|
|
110
|
+
const shift = packageId[0] % seed.length;
|
|
111
|
+
for (let i = 0; i < seed.length; ++i) {
|
|
112
|
+
const sourceIndex = (i + shift) % seed.length;
|
|
113
|
+
seed[sourceIndex] = maskedSeed[i] ^ salt[i]
|
|
114
|
+
^ packageId[i % packageId.length] ^ ((i * 29 + 17) & 0xff);
|
|
115
|
+
}
|
|
116
|
+
return seed;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* --- 解密带认证标签的 CGA AES-GCM 数据块 ---
|
|
121
|
+
* @param data 密文和末尾认证标签
|
|
122
|
+
* @param key 解密密钥
|
|
123
|
+
* @param nonce 随机数
|
|
124
|
+
* @param aad 附加认证数据
|
|
125
|
+
* @returns 明文
|
|
126
|
+
*/
|
|
127
|
+
function decryptCga(data, key, nonce, aad) {
|
|
128
|
+
assert.ok(data.length >= 16, 'encrypted CGA data must include an authentication tag');
|
|
129
|
+
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
|
|
130
|
+
decipher.setAAD(aad);
|
|
131
|
+
decipher.setAuthTag(data.subarray(-16));
|
|
132
|
+
return Buffer.concat([decipher.update(data.subarray(0, -16)), decipher.final()]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* --- 加载 CGA 清单并提供按路径读取能力 ---
|
|
137
|
+
* @param path CGA 归档路径
|
|
138
|
+
* @returns CGA 读取器
|
|
139
|
+
*/
|
|
140
|
+
async function loadCgaArchive(path) {
|
|
141
|
+
const data = await readArchive(path);
|
|
142
|
+
try {
|
|
143
|
+
assert.ok(data.length >= cgaHeaderLength, 'file is shorter than the CGA header');
|
|
144
|
+
assert.equal(data.subarray(0, 5).toString(), '-CGA-', 'CGA magic is invalid');
|
|
145
|
+
assert.equal(data.readUInt8(5), 1, 'CGA version is unsupported');
|
|
146
|
+
|
|
147
|
+
const iconLength = data.readUInt32BE(6);
|
|
148
|
+
const manifestLength = data.readUInt32BE(10);
|
|
149
|
+
const packageId = data.subarray(14, 30);
|
|
150
|
+
const salt = data.subarray(30, 62);
|
|
151
|
+
const maskedSeed = data.subarray(62, 94);
|
|
152
|
+
const manifestNonce = data.subarray(94, cgaHeaderLength);
|
|
153
|
+
const manifestOffset = cgaHeaderLength + iconLength;
|
|
154
|
+
const blockOffset = manifestOffset + manifestLength;
|
|
155
|
+
assert.ok(blockOffset <= data.length, 'CGA manifest exceeds the archive size');
|
|
156
|
+
|
|
157
|
+
const seed = unmaskCgaSeed(maskedSeed, salt, packageId);
|
|
158
|
+
const key = createHash('sha256').update(Buffer.concat([
|
|
159
|
+
seed, salt, packageId, cgaKeyContext,
|
|
160
|
+
])).digest();
|
|
161
|
+
const manifestData = decryptCga(
|
|
162
|
+
data.subarray(manifestOffset, blockOffset),
|
|
163
|
+
key,
|
|
164
|
+
manifestNonce,
|
|
165
|
+
Buffer.concat([packageId, Buffer.from('manifest')]),
|
|
166
|
+
);
|
|
167
|
+
const manifest = JSON.parse(manifestData.toString());
|
|
168
|
+
const blocks = new Map();
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
/**
|
|
172
|
+
* --- 从加密应用包读取单个文件 ---
|
|
173
|
+
* @param entryPath 包内绝对路径
|
|
174
|
+
* @returns 解密后的文件内容
|
|
175
|
+
*/
|
|
176
|
+
async read(entryPath) {
|
|
177
|
+
const entry = manifest.f?.[entryPath];
|
|
178
|
+
assert.ok(entry, archiveError(path, `required entry ${entryPath} is absent`));
|
|
179
|
+
let blockZip = blocks.get(entry.b);
|
|
180
|
+
if (!blockZip) {
|
|
181
|
+
const block = manifest.b?.[entry.b];
|
|
182
|
+
assert.ok(block, `manifest block ${entry.b} is absent`);
|
|
183
|
+
const start = blockOffset + block.o;
|
|
184
|
+
const end = start + block.l;
|
|
185
|
+
assert.ok((start >= blockOffset) && (end <= data.length), `manifest block ${entry.b} exceeds the archive size`);
|
|
186
|
+
const blockData = decryptCga(
|
|
187
|
+
data.subarray(start, end),
|
|
188
|
+
key,
|
|
189
|
+
Buffer.from(block.n, 'base64'),
|
|
190
|
+
Buffer.concat([packageId, Buffer.from(entry.b)]),
|
|
191
|
+
);
|
|
192
|
+
blockZip = await JSZip.loadAsync(blockData);
|
|
193
|
+
blocks.set(entry.b, blockZip);
|
|
194
|
+
}
|
|
195
|
+
const file = blockZip.file(entry.e);
|
|
196
|
+
assert.ok(file, `manifest entry ${entryPath} points to a missing file`);
|
|
197
|
+
return await file.async('nodebuffer');
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (error.message.includes('is missing or stale')) {
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
throw new Error(archiveError(path, `the encrypted package cannot be decoded (${error.message})`));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* --- 取得 AST 静态属性名 ---
|
|
211
|
+
* @param name 属性名节点
|
|
212
|
+
* @returns 静态名称;动态属性返回空字符串
|
|
213
|
+
*/
|
|
214
|
+
function getPropertyName(name) {
|
|
215
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
|
|
216
|
+
return name.text;
|
|
217
|
+
}
|
|
218
|
+
return '';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* --- 将只含静态字符串的对象字面量转换为普通对象 ---
|
|
223
|
+
* @param node 对象字面量节点
|
|
224
|
+
* @param label 诊断标签
|
|
225
|
+
* @returns 普通对象
|
|
226
|
+
*/
|
|
227
|
+
function readStaticObject(node, label) {
|
|
228
|
+
const result = {};
|
|
229
|
+
for (const property of node.properties) {
|
|
230
|
+
assert.equal(ts.isPropertyAssignment(property), true, `${label} must contain only static properties`);
|
|
231
|
+
const name = getPropertyName(property.name);
|
|
232
|
+
assert.notEqual(name, '', `${label} contains a dynamic property name`);
|
|
233
|
+
assert.equal(Object.hasOwn(result, name), false, `${label} repeats property ${name}`);
|
|
234
|
+
const value = property.initializer;
|
|
235
|
+
if (ts.isObjectLiteralExpression(value)) {
|
|
236
|
+
result[name] = readStaticObject(value, `${label}.${name}`);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const isText = ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value);
|
|
240
|
+
assert.equal(isText, true, `${label}.${name} must be a static string`);
|
|
241
|
+
result[name] = value.text;
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* --- 从控件源码或打包 JS 中读取 localeData ---
|
|
248
|
+
* @param code TypeScript 或 JavaScript 源码
|
|
249
|
+
* @param label 诊断标签
|
|
250
|
+
* @param scriptKind 脚本类型
|
|
251
|
+
* @returns localeData 普通对象
|
|
252
|
+
*/
|
|
253
|
+
function readLocaleData(code, label, scriptKind) {
|
|
254
|
+
const source = ts.createSourceFile(label, code, ts.ScriptTarget.Latest, true, scriptKind);
|
|
255
|
+
const candidates = [];
|
|
256
|
+
const visit = (node) => {
|
|
257
|
+
if (
|
|
258
|
+
ts.isPropertyDeclaration(node)
|
|
259
|
+
&& (getPropertyName(node.name) === 'localeData')
|
|
260
|
+
&& ts.isObjectLiteralExpression(node.initializer)
|
|
261
|
+
) {
|
|
262
|
+
candidates.push(node.initializer);
|
|
263
|
+
}
|
|
264
|
+
ts.forEachChild(node, visit);
|
|
265
|
+
};
|
|
266
|
+
visit(source);
|
|
267
|
+
assert.equal(candidates.length, 1, `${label} must contain exactly one static localeData table`);
|
|
268
|
+
return readStaticObject(candidates[0], `${label} localeData`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* --- 断言归档代码与本地 pack 产物完全一致 ---
|
|
273
|
+
* @param archive cgc 路径
|
|
274
|
+
* @param control 控件名
|
|
275
|
+
* @returns 归档代码
|
|
276
|
+
*/
|
|
277
|
+
async function assertControlCodeCurrent(archive, control) {
|
|
278
|
+
const archived = await readZipText(archive, `${control}/code.js`);
|
|
279
|
+
let generated;
|
|
280
|
+
try {
|
|
281
|
+
generated = (await readProjectFile(`dist/sources/control/${control}/code.pack.js`)).toString();
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
if (error?.code === 'ENOENT') {
|
|
285
|
+
throw new Error(archiveError(archive, `generated prerequisite ${control}/code.pack.js is absent`));
|
|
286
|
+
}
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
assert.equal(archived, generated, archiveError(archive, `${control}/code.js differs from code.pack.js`));
|
|
290
|
+
return archived;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* --- 断言归档 CSS 与本地编译 CSS 完全一致 ---
|
|
295
|
+
* @param archive cgc/cgt 路径
|
|
296
|
+
* @param entry 归档内 CSS 路径
|
|
297
|
+
* @param sourcePath 本地生成 CSS 路径
|
|
298
|
+
* @returns 归档 CSS
|
|
299
|
+
*/
|
|
300
|
+
async function assertCssCurrent(archive, entry, sourcePath) {
|
|
301
|
+
const archived = await readZipText(archive, entry);
|
|
302
|
+
const source = (await readProjectFile(sourcePath)).toString();
|
|
303
|
+
const result = new CleanCSS().minify(source);
|
|
304
|
+
assert.deepEqual(result.errors, [], `${sourcePath} must be valid CSS`);
|
|
305
|
+
assert.equal(archived, result.styles, archiveError(archive, `${entry} differs from ${sourcePath}`));
|
|
306
|
+
return archived;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- task.cga 必须包含与源码一致的 16 个语言包 ---
|
|
310
|
+
const taskArchivePath = 'dist/app/task.cga';
|
|
311
|
+
const taskArchive = await loadCgaArchive(taskArchivePath);
|
|
312
|
+
const archivedTaskConfigText = (await taskArchive.read('/config.json')).toString();
|
|
313
|
+
const sourceTaskConfigText = (await readProjectFile('dist/app/task/config.json')).toString();
|
|
314
|
+
const archivedTaskConfig = JSON.parse(archivedTaskConfigText);
|
|
315
|
+
const sourceTaskConfig = JSON.parse(sourceTaskConfigText);
|
|
316
|
+
assert.deepEqual(archivedTaskConfig, sourceTaskConfig, archiveError(taskArchivePath, '/config.json differs from its source'));
|
|
317
|
+
assert.deepEqual(Object.values(archivedTaskConfig.locales), codes, archiveError(taskArchivePath, 'its locale registry is not the agreed 16 locales'));
|
|
318
|
+
|
|
319
|
+
const archivedTaskApp = (await taskArchive.read('/app.js')).toString();
|
|
320
|
+
const generatedTaskApp = (await readProjectFile('dist/app/task/app.pack.js')).toString();
|
|
321
|
+
assert.equal(archivedTaskApp, generatedTaskApp, archiveError(taskArchivePath, '/app.js differs from app.pack.js'));
|
|
322
|
+
|
|
323
|
+
let taskLocaleKeys;
|
|
324
|
+
for (const code of codes) {
|
|
325
|
+
const entry = `/locale/${code}.json`;
|
|
326
|
+
const archivedText = (await taskArchive.read(entry)).toString();
|
|
327
|
+
const sourceText = (await readProjectFile(`dist/app/task/locale/${code}.json`)).toString();
|
|
328
|
+
const archived = JSON.parse(archivedText);
|
|
329
|
+
const source = JSON.parse(sourceText);
|
|
330
|
+
assert.deepEqual(archived, source, archiveError(taskArchivePath, `${entry} differs from its source`));
|
|
331
|
+
taskLocaleKeys ??= Object.keys(archived).sort();
|
|
332
|
+
assert.deepEqual(Object.keys(archived).sort(), taskLocaleKeys, archiveError(taskArchivePath, `${entry} has incomplete translation keys`));
|
|
333
|
+
if (code === 'ar') {
|
|
334
|
+
assert.match(JSON.stringify(archived), /[\u0600-\u06ff]/, archiveError(taskArchivePath, `${entry} has no Arabic text`));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// --- 19 个控件的归档语言表必须与 TypeScript 源码一致 ---
|
|
339
|
+
const archivedControlCode = new Map();
|
|
340
|
+
for (const control of localeControls) {
|
|
341
|
+
const packageName = commonControls.has(control) ? 'common' : control;
|
|
342
|
+
const archive = `dist/control/${packageName}.cgc`;
|
|
343
|
+
const archivedCode = await assertControlCodeCurrent(archive, control);
|
|
344
|
+
archivedControlCode.set(control, archivedCode);
|
|
345
|
+
const sourceCode = (await readProjectFile(`dist/sources/control/${control}/code.ts`)).toString();
|
|
346
|
+
const archivedLocale = readLocaleData(archivedCode, `${archive}:${control}/code.js`, ts.ScriptKind.JS);
|
|
347
|
+
const sourceLocale = readLocaleData(sourceCode, `dist/sources/control/${control}/code.ts`, ts.ScriptKind.TS);
|
|
348
|
+
assert.deepEqual(
|
|
349
|
+
Object.keys(archivedLocale).sort(),
|
|
350
|
+
[...codes].sort(),
|
|
351
|
+
archiveError(archive, `${control}/code.js does not contain exactly 16 locales`),
|
|
352
|
+
);
|
|
353
|
+
assert.deepEqual(archivedLocale, sourceLocale, archiveError(archive, `${control}/code.js localeData differs from TypeScript source`));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// --- 关键滚动与编辑器方向边界必须进入 cgc ---
|
|
357
|
+
const rtlCodeChecks = [
|
|
358
|
+
['dist/control/common.cgc', 'flow', ['getScrollInlineOffset', 'setScrollInlineOffset']],
|
|
359
|
+
['dist/control/common.cgc', 'vflow', ['isRtl']],
|
|
360
|
+
['dist/control/common.cgc', 'scroll', ['isRtl', 'logicalOffsetPx']],
|
|
361
|
+
['dist/control/common.cgc', 'text', ['getScrollInlineOffset', 'setScrollInlineOffset']],
|
|
362
|
+
['dist/control/common.cgc', 'select', ['getScrollLeft', 'setScrollLeft']],
|
|
363
|
+
['dist/control/common.cgc', 'tab', ['getScrollInlineOffset', 'setScrollInlineOffset']],
|
|
364
|
+
['dist/control/common.cgc', 'toolbar', ['getScrollInlineOffset', 'setScrollLeft']],
|
|
365
|
+
['dist/control/common.cgc', 'date', ['autoPosition', 'autoScroll']],
|
|
366
|
+
['dist/control/common.cgc', 'daterange', ['autoPosition', 'autoScroll']],
|
|
367
|
+
['dist/control/table.cgc', 'table', ['localeDirection', 'getScrollLeft', 'setScrollInlineOffset']],
|
|
368
|
+
['dist/control/desc.cgc', 'desc', ['getScrollInlineOffset', 'setScrollInlineOffset']],
|
|
369
|
+
['dist/control/iconview.cgc', 'iconview', ['isRtl']],
|
|
370
|
+
['dist/control/swiper.cgc', 'swiper', ['isRtl', 'directionVersion']],
|
|
371
|
+
['dist/control/jodit.cgc', 'jodit', ['_refreshDirection', 'direction', 'rtl']],
|
|
372
|
+
['dist/control/tuieditor.cgc', 'tuieditor', ['_refreshDirection', 'ProseMirror', 'rtl']],
|
|
373
|
+
['dist/control/monaco.cgc', 'monaco', ['documentElement.dir', 'ltr']],
|
|
374
|
+
];
|
|
375
|
+
for (const [archive, control, tokens] of rtlCodeChecks) {
|
|
376
|
+
const archivedCode = archivedControlCode.get(control) ?? await assertControlCodeCurrent(archive, control);
|
|
377
|
+
for (const token of tokens) {
|
|
378
|
+
assert.ok(archivedCode.includes(token), archiveError(archive, `${control}/code.js lacks RTL marker ${token}`));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// --- 控件 RTL 样式必须进入 cgc,并与 Sass 生成 CSS 一致 ---
|
|
383
|
+
const rtlStyleChecks = [
|
|
384
|
+
['dist/control/common.cgc', 'button/style.css', 'dist/sources/control/button/style.css', [':dir(rtl)', 'border-inline-end']],
|
|
385
|
+
['dist/control/common.cgc', 'list/style.css', 'dist/sources/control/list/style.css', [':dir(rtl)', 'margin-inline-end']],
|
|
386
|
+
['dist/control/common.cgc', 'tab/style.css', 'dist/sources/control/tab/style.css', ['border-inline-start-width', 'border-inline-end-width']],
|
|
387
|
+
['dist/control/common.cgc', 'timeline/style.css', 'dist/sources/control/timeline/style.css', ['border-inline-start']],
|
|
388
|
+
['dist/control/table.cgc', 'table/style.css', 'dist/sources/control/table/style.css', ['border-inline-end', 'inset-inline-end']],
|
|
389
|
+
['dist/control/xterm.cgc', 'xterm/style.css', 'dist/sources/control/xterm/style.css', ['direction:ltr']],
|
|
390
|
+
];
|
|
391
|
+
for (const [archive, entry, sourcePath, tokens] of rtlStyleChecks) {
|
|
392
|
+
const archivedCss = await assertCssCurrent(archive, entry, sourcePath);
|
|
393
|
+
for (const token of tokens) {
|
|
394
|
+
assert.ok(archivedCss.includes(token), archiveError(archive, `${entry} lacks RTL/LTR boundary marker ${token}`));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// --- 六套主题必须全部包含最新的逻辑方向样式 ---
|
|
399
|
+
for (const theme of themes) {
|
|
400
|
+
const archive = `dist/theme/${theme}.cgt`;
|
|
401
|
+
const archivedConfig = JSON.parse(await readZipText(archive, 'config.json'));
|
|
402
|
+
const sourceConfig = JSON.parse((await readProjectFile(`dist/sources/theme/${theme}/config.json`)).toString());
|
|
403
|
+
assert.deepEqual(archivedConfig, sourceConfig, archiveError(archive, 'config.json differs from its source'));
|
|
404
|
+
const archivedCss = await assertCssCurrent(
|
|
405
|
+
archive,
|
|
406
|
+
'style.css',
|
|
407
|
+
`dist/sources/theme/${theme}/style.css`,
|
|
408
|
+
);
|
|
409
|
+
assert.match(
|
|
410
|
+
archivedCss,
|
|
411
|
+
/(?:border|inset|margin|padding)-inline/,
|
|
412
|
+
archiveError(archive, 'style.css has no logical inline-direction properties'),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
console.log('Archive check passed: task.cga has 16 locales, 19 control locale tables are current, and RTL output is present in all 6 themes.');
|