locale-sync-cli 1.0.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/README.md +800 -0
- package/bin/locale-sync.js +72 -0
- package/config/default.json +18 -0
- package/lib/cli/commands/ignore.js +79 -0
- package/lib/cli/commands/init.js +59 -0
- package/lib/cli/commands/pull.js +60 -0
- package/lib/cli/commands/push.js +136 -0
- package/lib/cli/commands/set-credentials.js +35 -0
- package/lib/cli/utils/credentials.js +66 -0
- package/lib/cli/utils/logger.js +9 -0
- package/lib/config/manager.js +44 -0
- package/lib/core/engines/pullEngine.js +160 -0
- package/lib/core/engines/pushEngine.js +367 -0
- package/lib/core/utils/placeholderChecker.js +26 -0
- package/lib/core/utils/stringNormalizer.js +22 -0
- package/lib/index.js +8 -0
- package/lib/io/googleSheets.js +220 -0
- package/lib/io/localFs/astUpdater.js +198 -0
- package/lib/io/localFs/scanner.js +242 -0
- package/package.json +40 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
const { google } = require('googleapis');
|
|
2
|
+
const pRetry = require('p-retry');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const BATCH_LIMIT = 400;
|
|
7
|
+
|
|
8
|
+
function columnIndexToLetter(index) {
|
|
9
|
+
let col = '';
|
|
10
|
+
let n = index + 1;
|
|
11
|
+
while (n > 0) {
|
|
12
|
+
const r = (n - 1) % 26;
|
|
13
|
+
col = String.fromCharCode(65 + r) + col;
|
|
14
|
+
n = Math.floor((n - 1) / 26);
|
|
15
|
+
}
|
|
16
|
+
return col;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function getClient(config) {
|
|
20
|
+
const scopes = ['https://www.googleapis.com/auth/spreadsheets'];
|
|
21
|
+
// 优先读取机器级环境变量(base64 编码的 service-account JSON)
|
|
22
|
+
const b64 = process.env.LOCALE_SYNC_CREDENTIALS;
|
|
23
|
+
if (b64) {
|
|
24
|
+
let credentials;
|
|
25
|
+
try {
|
|
26
|
+
credentials = JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error('LOCALE_SYNC_CREDENTIALS 格式无效,请重新运行 locale-sync set-credentials');
|
|
29
|
+
}
|
|
30
|
+
const auth = new google.auth.GoogleAuth({ credentials, scopes });
|
|
31
|
+
return google.sheets({ version: 'v4', auth });
|
|
32
|
+
}
|
|
33
|
+
// 向后兼容:config 传字符串(keyFile 路径)或对象带 keyFile 字段
|
|
34
|
+
// 路径相对于运行命令的项目根目录(process.cwd())解析
|
|
35
|
+
const rawKeyFile = typeof config === 'string' ? config : config?.keyFile;
|
|
36
|
+
const keyFile = rawKeyFile
|
|
37
|
+
? (path.isAbsolute(rawKeyFile) ? rawKeyFile : path.join(process.cwd(), rawKeyFile))
|
|
38
|
+
: path.join(process.cwd(), 'service-account.json');
|
|
39
|
+
if (!fs.existsSync(keyFile)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`找不到 Google 凭证:请设置环境变量 LOCALE_SYNC_CREDENTIALS,\n` +
|
|
42
|
+
`或将 service-account.json 放在项目根目录(${process.cwd()}),\n` +
|
|
43
|
+
`或运行 locale-sync init 进行配置。`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const auth = new google.auth.GoogleAuth({ keyFile, scopes });
|
|
47
|
+
return google.sheets({ version: 'v4', auth });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function ensureMetadataColumns(sheets, spreadsheetId, sheetName, baseHeaders = null) {
|
|
51
|
+
const meta = await sheets.spreadsheets.get({ spreadsheetId, fields: 'sheets(properties(title))' });
|
|
52
|
+
const exists = meta.data.sheets.some(s => s.properties.title === sheetName);
|
|
53
|
+
if (!exists) {
|
|
54
|
+
await sheets.spreadsheets.batchUpdate({
|
|
55
|
+
spreadsheetId,
|
|
56
|
+
requestBody: { requests: [{ addSheet: { properties: { title: sheetName } } }] }
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const headersRes = await sheets.spreadsheets.values.get({ spreadsheetId, range: `${sheetName}!1:1` });
|
|
61
|
+
const headers = headersRes.data.values?.[0] || [];
|
|
62
|
+
|
|
63
|
+
// 空 sheet 且传入了基础列定义时,自动初始化完整 header 行(含元数据列)
|
|
64
|
+
if (headers.length === 0) {
|
|
65
|
+
if (!baseHeaders || baseHeaders.length === 0) return;
|
|
66
|
+
const fullHeaders = [...baseHeaders, '_lastSyncBy', '_lastSyncAt'];
|
|
67
|
+
await sheets.spreadsheets.values.update({
|
|
68
|
+
spreadsheetId,
|
|
69
|
+
range: `${sheetName}!A1`,
|
|
70
|
+
valueInputOption: 'RAW',
|
|
71
|
+
requestBody: { values: [fullHeaders] }
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const normalizedHeaders = headers.map(h => h.toLowerCase().replace(/[-_]/g, ''));
|
|
77
|
+
const missing = [
|
|
78
|
+
{ col: '_lastSyncBy', norm: 'lastsyncby' },
|
|
79
|
+
{ col: '_lastSyncAt', norm: 'lastsyncat' }
|
|
80
|
+
].filter(({ norm }) => !normalizedHeaders.includes(norm)).map(({ col }) => col);
|
|
81
|
+
|
|
82
|
+
if (missing.length) {
|
|
83
|
+
const startCol = columnIndexToLetter(headers.length);
|
|
84
|
+
await sheets.spreadsheets.values.update({
|
|
85
|
+
spreadsheetId,
|
|
86
|
+
range: `${sheetName}!${startCol}1`,
|
|
87
|
+
valueInputOption: 'RAW',
|
|
88
|
+
requestBody: { values: [missing] }
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function readSheet(sheets, spreadsheetId, sheetName) {
|
|
94
|
+
const range = `${sheetName}!A:ZZ`;
|
|
95
|
+
const [valuesRes, formatRes] = await Promise.all([
|
|
96
|
+
sheets.spreadsheets.values.get({ spreadsheetId, range, valueRenderOption: 'UNFORMATTED_VALUE' }),
|
|
97
|
+
sheets.spreadsheets.get({ spreadsheetId, ranges: [range], fields: 'sheets(properties(title,sheetId),data(rowData(values(userEnteredFormat.backgroundColor))))' })
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
const rows = valuesRes.data.values || [];
|
|
101
|
+
const matchedSheet = formatRes.data.sheets?.find(s => s.properties?.title === sheetName);
|
|
102
|
+
const actualSheetId = matchedSheet?.properties?.sheetId ?? 0;
|
|
103
|
+
const headers = rows[0] || [];
|
|
104
|
+
const headerMap = headers.map((h, i) => ({ key: h.toLowerCase().replace(/[-_]/g, ''), index: i }));
|
|
105
|
+
const rowData = matchedSheet?.data?.[0]?.rowData || [];
|
|
106
|
+
|
|
107
|
+
// 即使没有数据行,也把 headers 和 sheetId 附到返回数组上,供 push 新增行时使用
|
|
108
|
+
const result = rows.slice(1).map((row, rIdx) => {
|
|
109
|
+
const entry = { _headers: headers, _sheetId: actualSheetId };
|
|
110
|
+
headerMap.forEach(({ key, index }) => {
|
|
111
|
+
entry[key] = row[index] || '';
|
|
112
|
+
});
|
|
113
|
+
const rowCells = rowData[rIdx + 1]?.values || [];
|
|
114
|
+
// 按列独立读取颜色,存入 _colors map(key 为归一化列名)
|
|
115
|
+
const colors = {};
|
|
116
|
+
headerMap.forEach(({ key, index }) => {
|
|
117
|
+
const fmt = rowCells[index]?.userEnteredFormat?.backgroundColor;
|
|
118
|
+
colors[key] = fmt ? rgbToColorName(fmt) : 'none';
|
|
119
|
+
});
|
|
120
|
+
entry._colors = colors;
|
|
121
|
+
entry._color = colors[headerMap[0]?.key] || 'none'; // 行级颜色(向后兼容)
|
|
122
|
+
entry._rowIndex = rIdx + 2;
|
|
123
|
+
return entry;
|
|
124
|
+
});
|
|
125
|
+
result.headers = headers;
|
|
126
|
+
result.sheetId = actualSheetId;
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function rgbToColorName(rgb) {
|
|
131
|
+
if (!rgb) return 'none';
|
|
132
|
+
if (rgb.red > 0.6 && rgb.green > 0.9 && rgb.blue > 0.6) return 'green';
|
|
133
|
+
if (rgb.red > 0.9 && rgb.green > 0.9 && rgb.blue > 0.6) return 'yellow';
|
|
134
|
+
if (rgb.red > 0.9 && rgb.green < 0.8 && rgb.blue < 0.8) return 'red';
|
|
135
|
+
if (rgb.red > 0.8 && rgb.green > 0.8 && rgb.blue > 0.8) return 'gray';
|
|
136
|
+
return 'none';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function colorToRGB(name) {
|
|
140
|
+
const map = {
|
|
141
|
+
green: { red: 0.7, green: 1, blue: 0.7 },
|
|
142
|
+
yellow: { red: 1, green: 1, blue: 0.7 },
|
|
143
|
+
red: { red: 1, green: 0.7, blue: 0.7 },
|
|
144
|
+
gray: { red: 0.9, green: 0.9, blue: 0.9 }
|
|
145
|
+
};
|
|
146
|
+
return map[name] || { red: 1, green: 1, blue: 1 };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 返回两类格式化请求:
|
|
151
|
+
* 1. 冻结首行
|
|
152
|
+
* 2. 首行高度 = 普通行 2 倍(42 px)
|
|
153
|
+
* 3. 首行:青色背景 + 粗体 + 居中
|
|
154
|
+
* 4. 数据区(row 1+):水平/垂直居中 + 自动换行
|
|
155
|
+
*/
|
|
156
|
+
function buildSheetFormatRequests(sheetId) {
|
|
157
|
+
const CYAN = { red: 0.0, green: 0.8, blue: 0.8 };
|
|
158
|
+
return [
|
|
159
|
+
// 1. 冻结首行
|
|
160
|
+
{
|
|
161
|
+
updateSheetProperties: {
|
|
162
|
+
properties: { sheetId, gridProperties: { frozenRowCount: 1 } },
|
|
163
|
+
fields: 'gridProperties.frozenRowCount'
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
// 2. 首行高度 42 px(普通行约 21 px 的 2 倍)
|
|
167
|
+
{
|
|
168
|
+
updateDimensionProperties: {
|
|
169
|
+
range: { sheetId, dimension: 'ROWS', startIndex: 0, endIndex: 1 },
|
|
170
|
+
properties: { pixelSize: 42 },
|
|
171
|
+
fields: 'pixelSize'
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
// 3. 首行:青色背景 + 粗体 + 水平/垂直居中 + 换行
|
|
175
|
+
{
|
|
176
|
+
repeatCell: {
|
|
177
|
+
range: { sheetId, startRowIndex: 0, endRowIndex: 1 },
|
|
178
|
+
cell: {
|
|
179
|
+
userEnteredFormat: {
|
|
180
|
+
backgroundColor: CYAN,
|
|
181
|
+
textFormat: { bold: true },
|
|
182
|
+
horizontalAlignment: 'CENTER',
|
|
183
|
+
verticalAlignment: 'MIDDLE',
|
|
184
|
+
wrapStrategy: 'WRAP'
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
fields: 'userEnteredFormat(backgroundColor,textFormat.bold,horizontalAlignment,verticalAlignment,wrapStrategy)'
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
// 4. 数据区(row 1 起):水平/垂直居中 + 换行
|
|
191
|
+
{
|
|
192
|
+
repeatCell: {
|
|
193
|
+
range: { sheetId, startRowIndex: 1 },
|
|
194
|
+
cell: {
|
|
195
|
+
userEnteredFormat: {
|
|
196
|
+
horizontalAlignment: 'CENTER',
|
|
197
|
+
verticalAlignment: 'MIDDLE',
|
|
198
|
+
wrapStrategy: 'WRAP'
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
fields: 'userEnteredFormat(horizontalAlignment,verticalAlignment,wrapStrategy)'
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function batchWrite(sheets, spreadsheetId, requests) {
|
|
208
|
+
if (requests.length === 0) return;
|
|
209
|
+
const chunks = [];
|
|
210
|
+
for (let i = 0; i < requests.length; i += BATCH_LIMIT) chunks.push(requests.slice(i, i + BATCH_LIMIT));
|
|
211
|
+
|
|
212
|
+
for (const chunk of chunks) {
|
|
213
|
+
await pRetry(
|
|
214
|
+
async () => sheets.spreadsheets.batchUpdate({ spreadsheetId, requestBody: { requests: chunk } }),
|
|
215
|
+
{ retries: 3, onFailedAttempt: () => {} }
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { getClient, ensureMetadataColumns, readSheet, batchWrite, colorToRGB, rgbToColorName, BATCH_LIMIT, buildSheetFormatRequests };
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const fs = require('fs').promises;
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const recast = require('recast');
|
|
4
|
+
const babelParser = require('recast/parsers/babel');
|
|
5
|
+
const { default: traverse } = require('@babel/traverse');
|
|
6
|
+
const t = require('@babel/types');
|
|
7
|
+
|
|
8
|
+
function buildKeyPath(nodePath) {
|
|
9
|
+
const segments = [];
|
|
10
|
+
let curr = nodePath;
|
|
11
|
+
while (curr) {
|
|
12
|
+
const node = curr.node;
|
|
13
|
+
const parentPath = curr.parentPath;
|
|
14
|
+
if (t.isObjectProperty(node) || t.isObjectMethod(node)) {
|
|
15
|
+
const k = node.key.name != null ? node.key.name : node.key.value;
|
|
16
|
+
if (k != null) segments.unshift(String(k));
|
|
17
|
+
}
|
|
18
|
+
// 当前节点在数组中:插入其索引
|
|
19
|
+
if (parentPath && t.isArrayExpression(parentPath.node)) {
|
|
20
|
+
const idx = parentPath.node.elements.indexOf(node);
|
|
21
|
+
if (idx >= 0) segments.unshift(String(idx));
|
|
22
|
+
}
|
|
23
|
+
if (!parentPath || t.isExportDefaultDeclaration(node) || t.isAssignmentExpression(node) || t.isFile(node)) break;
|
|
24
|
+
curr = parentPath;
|
|
25
|
+
}
|
|
26
|
+
return segments.join('.');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 将 TemplateLiteral AST 节点序列化为带 ${...} 占位符的字符串
|
|
31
|
+
* 例: `list3${process.env.common}action start!` → "list3${process.env.common}action start!"
|
|
32
|
+
*/
|
|
33
|
+
function serializeTemplateLiteral(node) {
|
|
34
|
+
let val = '';
|
|
35
|
+
for (let i = 0; i < node.quasis.length; i++) {
|
|
36
|
+
val += node.quasis[i].value.cooked || '';
|
|
37
|
+
if (i < node.expressions.length) {
|
|
38
|
+
val += '${' + recast.print(node.expressions[i]).code + '}';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return val;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 将字符串值回填到 TemplateLiteral AST 节点的 quasis
|
|
46
|
+
* 按原始 expression 源码作为分隔符拆分,逐段更新 quasis
|
|
47
|
+
*/
|
|
48
|
+
function applyToTemplateLiteral(tlNode, newValue) {
|
|
49
|
+
if (tlNode.expressions.length === 0) {
|
|
50
|
+
if (tlNode.quasis[0]) {
|
|
51
|
+
tlNode.quasis[0].value.raw = newValue;
|
|
52
|
+
tlNode.quasis[0].value.cooked = newValue;
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const exprSources = tlNode.expressions.map(e => recast.print(e).code);
|
|
57
|
+
let remaining = newValue;
|
|
58
|
+
const parts = [];
|
|
59
|
+
for (const exprSrc of exprSources) {
|
|
60
|
+
const placeholder = '${' + exprSrc + '}';
|
|
61
|
+
const idx = remaining.indexOf(placeholder);
|
|
62
|
+
if (idx === -1) {
|
|
63
|
+
// 占位符缺失(用户删除了表达式):全部写入第一个 quasi,其余置空
|
|
64
|
+
if (tlNode.quasis[0]) {
|
|
65
|
+
tlNode.quasis[0].value.raw = newValue;
|
|
66
|
+
tlNode.quasis[0].value.cooked = newValue;
|
|
67
|
+
}
|
|
68
|
+
for (let i = 1; i < tlNode.quasis.length; i++) {
|
|
69
|
+
tlNode.quasis[i].value.raw = '';
|
|
70
|
+
tlNode.quasis[i].value.cooked = '';
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
parts.push(remaining.slice(0, idx));
|
|
75
|
+
remaining = remaining.slice(idx + placeholder.length);
|
|
76
|
+
}
|
|
77
|
+
parts.push(remaining);
|
|
78
|
+
for (let i = 0; i < parts.length; i++) {
|
|
79
|
+
if (tlNode.quasis[i]) {
|
|
80
|
+
tlNode.quasis[i].value.raw = parts[i];
|
|
81
|
+
tlNode.quasis[i].value.cooked = parts[i];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 按 dot-path 设置嵌套对象的值
|
|
87
|
+
function setNestedValue(obj, keyPath, value) {
|
|
88
|
+
const keys = keyPath.split('.');
|
|
89
|
+
let cur = obj;
|
|
90
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
91
|
+
if (cur[keys[i]] == null || typeof cur[keys[i]] !== 'object') return false;
|
|
92
|
+
cur = cur[keys[i]];
|
|
93
|
+
}
|
|
94
|
+
const last = keys[keys.length - 1];
|
|
95
|
+
if (!(last in cur)) return false;
|
|
96
|
+
cur[last] = value;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function applyJsonFileUpdates(filePath, updates, dryRun, results) {
|
|
101
|
+
const CommentJSON = require('comment-json');
|
|
102
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
103
|
+
const data = CommentJSON.parse(content);
|
|
104
|
+
let modified = false;
|
|
105
|
+
for (const u of updates) {
|
|
106
|
+
if (setNestedValue(data, u.key, u.newValue)) modified = true;
|
|
107
|
+
}
|
|
108
|
+
if (modified && !dryRun) {
|
|
109
|
+
await fs.writeFile(filePath, CommentJSON.stringify(data, null, 2), 'utf8');
|
|
110
|
+
}
|
|
111
|
+
if (modified) results.updated++;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function applyYamlFileUpdates(filePath, updates, dryRun, results) {
|
|
115
|
+
const yaml = require('js-yaml');
|
|
116
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
117
|
+
const data = yaml.load(content);
|
|
118
|
+
let modified = false;
|
|
119
|
+
for (const u of updates) {
|
|
120
|
+
if (setNestedValue(data, u.key, u.newValue)) modified = true;
|
|
121
|
+
}
|
|
122
|
+
if (modified && !dryRun) {
|
|
123
|
+
await fs.writeFile(filePath, yaml.dump(data, { lineWidth: -1, noRefs: true }), 'utf8');
|
|
124
|
+
}
|
|
125
|
+
if (modified) results.updated++;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function applyJsAstFileUpdates(filePath, updates, dryRun, results) {
|
|
129
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
130
|
+
const ast = recast.parse(content, { parser: babelParser });
|
|
131
|
+
let modified = false;
|
|
132
|
+
|
|
133
|
+
traverse(ast, {
|
|
134
|
+
ObjectProperty(nodePath) {
|
|
135
|
+
if (!t.isStringLiteral(nodePath.node.value) && !t.isTemplateLiteral(nodePath.node.value)) return;
|
|
136
|
+
const keyPath = buildKeyPath(nodePath);
|
|
137
|
+
const update = updates.find(u => u.key === keyPath);
|
|
138
|
+
if (!update) return;
|
|
139
|
+
const newVal = update.newValue;
|
|
140
|
+
if (t.isStringLiteral(nodePath.node.value)) {
|
|
141
|
+
nodePath.node.value.value = newVal;
|
|
142
|
+
} else if (t.isTemplateLiteral(nodePath.node.value)) {
|
|
143
|
+
applyToTemplateLiteral(nodePath.node.value, newVal);
|
|
144
|
+
}
|
|
145
|
+
modified = true;
|
|
146
|
+
},
|
|
147
|
+
// 数组元素(StringLiteral)
|
|
148
|
+
StringLiteral(nodePath) {
|
|
149
|
+
if (!t.isArrayExpression(nodePath.parentPath?.node)) return;
|
|
150
|
+
const keyPath = buildKeyPath(nodePath);
|
|
151
|
+
const update = updates.find(u => u.key === keyPath);
|
|
152
|
+
if (!update) return;
|
|
153
|
+
nodePath.node.value = update.newValue;
|
|
154
|
+
modified = true;
|
|
155
|
+
},
|
|
156
|
+
// 数组元素(TemplateLiteral,原样按 ${...} 分段回填)
|
|
157
|
+
TemplateLiteral(nodePath) {
|
|
158
|
+
if (!t.isArrayExpression(nodePath.parentPath?.node)) return;
|
|
159
|
+
const keyPath = buildKeyPath(nodePath);
|
|
160
|
+
const update = updates.find(u => u.key === keyPath);
|
|
161
|
+
if (!update) return;
|
|
162
|
+
applyToTemplateLiteral(nodePath.node, update.newValue);
|
|
163
|
+
modified = true;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (modified && !dryRun) {
|
|
168
|
+
const output = recast.print(ast, { quote: 'single', trailingComma: true, tabWidth: 2 }).code;
|
|
169
|
+
await fs.writeFile(filePath, output, 'utf8');
|
|
170
|
+
}
|
|
171
|
+
if (modified) results.updated++;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function applyAstUpdates(plan, dryRun) {
|
|
175
|
+
const results = { updated: 0, warnings: [] };
|
|
176
|
+
const fileGroups = plan.reduce((acc, u) => {
|
|
177
|
+
(acc[u.filePath] = acc[u.filePath] || []).push(u);
|
|
178
|
+
return acc;
|
|
179
|
+
}, {});
|
|
180
|
+
|
|
181
|
+
for (const [filePath, updates] of Object.entries(fileGroups)) {
|
|
182
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
183
|
+
try {
|
|
184
|
+
if (ext === '.json') {
|
|
185
|
+
await applyJsonFileUpdates(filePath, updates, dryRun, results);
|
|
186
|
+
} else if (ext === '.yaml' || ext === '.yml') {
|
|
187
|
+
await applyYamlFileUpdates(filePath, updates, dryRun, results);
|
|
188
|
+
} else {
|
|
189
|
+
await applyJsAstFileUpdates(filePath, updates, dryRun, results);
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
results.warnings.push(`[UPDATE_ERROR] ${filePath}: ${err.message}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { applyAstUpdates, buildKeyPath, serializeTemplateLiteral };
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
const fastGlob = require('fast-glob');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs').promises;
|
|
4
|
+
const { parse } = require('recast');
|
|
5
|
+
const babelParser = require('recast/parsers/babel');
|
|
6
|
+
const { default: traverse } = require('@babel/traverse');
|
|
7
|
+
const t = require('@babel/types');
|
|
8
|
+
const { buildKeyPath, serializeTemplateLiteral } = require('./astUpdater');
|
|
9
|
+
const { normalizeForAnchor } = require('../../core/utils/stringNormalizer');
|
|
10
|
+
|
|
11
|
+
// 匹配 locale 代码:en, zh, enUS, arAE, en-US, zh_CN 等
|
|
12
|
+
const LOCALE_RE = /^[a-z]{2,3}([A-Z]{2,3}|[-_][a-zA-Z]{2,3})?$/;
|
|
13
|
+
|
|
14
|
+
async function scanLocales(localesDirs, ignore = []) {
|
|
15
|
+
const map = {};
|
|
16
|
+
const dirs = Array.isArray(localesDirs) ? localesDirs : [localesDirs];
|
|
17
|
+
|
|
18
|
+
// 将用户配置的忽略规则规范化为 fast-glob 兼容的 glob 模式
|
|
19
|
+
// - 无点无斜线 → 文件夹名:node_modules → **/node_modules/**
|
|
20
|
+
// - 有点无斜线 → 文件名或扩展:*.d.ts / index.js → **/*.d.ts
|
|
21
|
+
// - 含斜线 → 项目根目录相对路径,转为相对于 locales 目录的路径后再套用上述规则
|
|
22
|
+
const toGlob = (t) => {
|
|
23
|
+
if (t.startsWith('*')) return [`**/${t}`];
|
|
24
|
+
if (t.includes('.')) return [`**/${t}`];
|
|
25
|
+
return [`**/${t}/**`, `**/${t}`];
|
|
26
|
+
};
|
|
27
|
+
const resolveIgnore = (resolvedDir) => (Array.isArray(ignore) ? ignore : []).flatMap(p => {
|
|
28
|
+
if (!p || typeof p !== 'string') return [];
|
|
29
|
+
const t = p.trim();
|
|
30
|
+
if (!t) return [];
|
|
31
|
+
if (t.includes('/')) {
|
|
32
|
+
const abs = path.resolve(t);
|
|
33
|
+
const rel = path.relative(resolvedDir, abs);
|
|
34
|
+
return rel.startsWith('..') ? [t] : toGlob(rel);
|
|
35
|
+
}
|
|
36
|
+
return toGlob(t);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
for (const dir of dirs) {
|
|
40
|
+
// 将配置路径解析为绝对路径,以便正确计算相对路径
|
|
41
|
+
const resolvedDir = path.isAbsolute(dir) ? dir : path.resolve(dir);
|
|
42
|
+
let files;
|
|
43
|
+
try {
|
|
44
|
+
files = await fastGlob(['**/*.{js,json,yaml,yml}'], { cwd: resolvedDir, absolute: true, ignore: resolveIgnore(resolvedDir) });
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.warn(`[SCAN WARN] Cannot scan dir: ${dir}`, e.message);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
// locale 检测策略(优先级):
|
|
52
|
+
// 1. 文件名(原始,未规范化)是合法 locale 代码(ar-SA.js → 'arsa',en-US.js → 'enus')
|
|
53
|
+
// 2. 任意目录层级(由浅到深)名称是合法 locale 代码(en-US/common.js 或 oom/en-US/common.js → 'enus')
|
|
54
|
+
// 3. 都不是 → 使用规范化后的文件名(多 locale 文件由 multi-locale 检测覆盖)
|
|
55
|
+
const relPath = path.relative(resolvedDir, file);
|
|
56
|
+
const relParts = relPath.split(path.sep);
|
|
57
|
+
const rawFileBase = path.basename(file, path.extname(file));
|
|
58
|
+
const fileBase = rawFileBase.toLowerCase().replace(/[-_]/g, '');
|
|
59
|
+
const dirParts = relParts.slice(0, -1); // 所有目录层级,不含文件名
|
|
60
|
+
const rawLocaleDir = dirParts.find(p => LOCALE_RE.test(p)) ?? null;
|
|
61
|
+
const localeDirNorm = rawLocaleDir ? rawLocaleDir.toLowerCase().replace(/[-_]/g, '') : null;
|
|
62
|
+
const locale = LOCALE_RE.test(rawFileBase)
|
|
63
|
+
? fileBase
|
|
64
|
+
: (rawLocaleDir ? localeDirNorm : fileBase);
|
|
65
|
+
|
|
66
|
+
const ext = path.extname(file).toLowerCase();
|
|
67
|
+
const entry = { filePath: file, keyMap: {}, values: {} };
|
|
68
|
+
let multiLocaleHandled = false;
|
|
69
|
+
|
|
70
|
+
if (ext === '.json') {
|
|
71
|
+
try {
|
|
72
|
+
const CommentJSON = require('comment-json');
|
|
73
|
+
const content = await fs.readFile(file, 'utf8');
|
|
74
|
+
const data = CommentJSON.parse(content);
|
|
75
|
+
if (isMultiLocaleObject(data)) {
|
|
76
|
+
mergeMultiLocale(data, file, map);
|
|
77
|
+
multiLocaleHandled = true;
|
|
78
|
+
} else {
|
|
79
|
+
flattenObj(data, '', entry);
|
|
80
|
+
}
|
|
81
|
+
} catch (e) { console.error(`[SCAN JSON ERROR] ${file}:`, e.message); }
|
|
82
|
+
} else if (ext === '.yaml' || ext === '.yml') {
|
|
83
|
+
try {
|
|
84
|
+
const yaml = require('js-yaml');
|
|
85
|
+
const content = await fs.readFile(file, 'utf8');
|
|
86
|
+
const data = yaml.load(content);
|
|
87
|
+
if (data && typeof data === 'object') {
|
|
88
|
+
if (isMultiLocaleObject(data)) {
|
|
89
|
+
mergeMultiLocale(data, file, map);
|
|
90
|
+
multiLocaleHandled = true;
|
|
91
|
+
} else {
|
|
92
|
+
flattenObj(data, '', entry);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch (e) { console.error(`[SCAN YAML ERROR] ${file}:`, e.message); }
|
|
96
|
+
} else {
|
|
97
|
+
try {
|
|
98
|
+
const content = await fs.readFile(file, 'utf8');
|
|
99
|
+
const ast = parse(content, { parser: babelParser });
|
|
100
|
+
traverse(ast, {
|
|
101
|
+
ObjectProperty(path) {
|
|
102
|
+
if (t.isStringLiteral(path.node.value) || t.isTemplateLiteral(path.node.value)) {
|
|
103
|
+
const kp = buildKeyPath(path);
|
|
104
|
+
if (!kp) return;
|
|
105
|
+
let val = '';
|
|
106
|
+
if (t.isStringLiteral(path.node.value)) val = path.node.value.value;
|
|
107
|
+
else val = serializeTemplateLiteral(path.node.value);
|
|
108
|
+
entry.keyMap[kp] = kp;
|
|
109
|
+
entry.values[kp] = val;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
// 数组元素:字符串字面量
|
|
113
|
+
StringLiteral(path) {
|
|
114
|
+
if (!t.isArrayExpression(path.parentPath?.node)) return;
|
|
115
|
+
const kp = buildKeyPath(path);
|
|
116
|
+
if (!kp) return;
|
|
117
|
+
entry.keyMap[kp] = kp;
|
|
118
|
+
entry.values[kp] = path.node.value;
|
|
119
|
+
},
|
|
120
|
+
// 数组元素:模板字面量(原样序列化含 ${...} 占位符)
|
|
121
|
+
TemplateLiteral(path) {
|
|
122
|
+
if (!t.isArrayExpression(path.parentPath?.node)) return;
|
|
123
|
+
const kp = buildKeyPath(path);
|
|
124
|
+
if (!kp) return;
|
|
125
|
+
entry.keyMap[kp] = kp;
|
|
126
|
+
entry.values[kp] = serializeTemplateLiteral(path.node);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
// JS 文件:检测多 locale 合体格式(顶层 key 均为 locale 代码)
|
|
130
|
+
const topKeys = [...new Set(Object.keys(entry.values).map(k => k.split('.')[0]))];
|
|
131
|
+
if (topKeys.length > 0 && topKeys.every(k => LOCALE_RE.test(k))) {
|
|
132
|
+
mergeMultiLocaleFlat(entry, topKeys, file, map);
|
|
133
|
+
multiLocaleHandled = true;
|
|
134
|
+
}
|
|
135
|
+
} catch (e) { console.error(`[SCAN AST ERROR] ${file}:`, e.message); }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!multiLocaleHandled) {
|
|
139
|
+
// 为每个 key 记录所在文件(多文件 locale 回填时精确定位)
|
|
140
|
+
entry.filePaths = {};
|
|
141
|
+
for (const key of Object.keys(entry.values)) {
|
|
142
|
+
entry.filePaths[key] = file;
|
|
143
|
+
}
|
|
144
|
+
if (map[locale]) {
|
|
145
|
+
Object.assign(map[locale].keyMap, entry.keyMap);
|
|
146
|
+
Object.assign(map[locale].values, entry.values);
|
|
147
|
+
Object.assign(map[locale].filePaths, entry.filePaths);
|
|
148
|
+
} else {
|
|
149
|
+
map[locale] = entry;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return map;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function flattenObj(obj, prefix, target) {
|
|
158
|
+
const entries = Array.isArray(obj)
|
|
159
|
+
? obj.map((v, i) => [String(i), v])
|
|
160
|
+
: Object.entries(obj);
|
|
161
|
+
for (const [k, v] of entries) {
|
|
162
|
+
const newKey = prefix ? `${prefix}.${k}` : k;
|
|
163
|
+
if (v !== null && typeof v === 'object') {
|
|
164
|
+
flattenObj(v, newKey, target);
|
|
165
|
+
} else {
|
|
166
|
+
target.keyMap[newKey] = newKey;
|
|
167
|
+
target.values[newKey] = String(v ?? '');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 判断 JSON/YAML 对象是否为"单文件多 locale"格式(顶层 key 均为 locale 代码) */
|
|
173
|
+
function isMultiLocaleObject(obj) {
|
|
174
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;
|
|
175
|
+
const keys = Object.keys(obj);
|
|
176
|
+
return keys.length > 0 && keys.every(k => LOCALE_RE.test(k));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 将 JSON/YAML 多 locale 对象拆分后合并到 map */
|
|
180
|
+
function mergeMultiLocale(data, file, map) {
|
|
181
|
+
for (const [localeKey, content] of Object.entries(data)) {
|
|
182
|
+
if (!content || typeof content !== 'object') continue;
|
|
183
|
+
const locale = localeKey.toLowerCase().replace(/[-_]/g, '');
|
|
184
|
+
const subEntry = { filePath: file, keyMap: {}, values: {}, filePaths: {} };
|
|
185
|
+
flattenObjMulti(content, '', localeKey, subEntry, file);
|
|
186
|
+
if (map[locale]) {
|
|
187
|
+
Object.assign(map[locale].keyMap, subEntry.keyMap);
|
|
188
|
+
Object.assign(map[locale].values, subEntry.values);
|
|
189
|
+
Object.assign(map[locale].filePaths, subEntry.filePaths);
|
|
190
|
+
} else {
|
|
191
|
+
map[locale] = subEntry;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* flattenObj 变体:keyMap 存储带 locale 前缀的完整写回路径
|
|
198
|
+
* 例: enUS.name → values['name'] = 'wang', keyMap['name'] = 'enUS.name'
|
|
199
|
+
*/
|
|
200
|
+
function flattenObjMulti(obj, prefix, localeKey, target, file) {
|
|
201
|
+
const entries = Array.isArray(obj)
|
|
202
|
+
? obj.map((v, i) => [String(i), v])
|
|
203
|
+
: Object.entries(obj);
|
|
204
|
+
for (const [k, v] of entries) {
|
|
205
|
+
const logicalKey = prefix ? `${prefix}.${k}` : k;
|
|
206
|
+
if (v !== null && typeof v === 'object') {
|
|
207
|
+
flattenObjMulti(v, logicalKey, localeKey, target, file);
|
|
208
|
+
} else {
|
|
209
|
+
target.keyMap[logicalKey] = `${localeKey}.${logicalKey}`; // 写回路径含 locale 前缀
|
|
210
|
+
target.values[logicalKey] = String(v ?? '');
|
|
211
|
+
target.filePaths[logicalKey] = file;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 将多 locale JS AST 结果(含 locale 前缀的 key)按 locale 拆分合并 */
|
|
217
|
+
function mergeMultiLocaleFlat(entry, topKeys, file, map) {
|
|
218
|
+
for (const lk of topKeys) {
|
|
219
|
+
const prefix = lk + '.';
|
|
220
|
+
const subEntry = { filePath: file, keyMap: {}, values: {}, filePaths: {} };
|
|
221
|
+
for (const [k, v] of Object.entries(entry.values)) {
|
|
222
|
+
if (k.startsWith(prefix)) {
|
|
223
|
+
const logicalKey = k.slice(prefix.length);
|
|
224
|
+
subEntry.keyMap[logicalKey] = k; // 写回时用完整 key(含 locale 前缀)
|
|
225
|
+
subEntry.values[logicalKey] = v;
|
|
226
|
+
subEntry.filePaths[logicalKey] = file;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const locale = lk.toLowerCase().replace(/[-_]/g, '');
|
|
230
|
+
if (Object.keys(subEntry.values).length > 0) {
|
|
231
|
+
if (map[locale]) {
|
|
232
|
+
Object.assign(map[locale].keyMap, subEntry.keyMap);
|
|
233
|
+
Object.assign(map[locale].values, subEntry.values);
|
|
234
|
+
Object.assign(map[locale].filePaths, subEntry.filePaths);
|
|
235
|
+
} else {
|
|
236
|
+
map[locale] = subEntry;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { scanLocales };
|