feima-shortcuts 0.3.0-beta.11 → 0.3.0-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feima-shortcuts",
3
- "version": "0.3.0-beta.11",
3
+ "version": "0.3.0-beta.12",
4
4
  "description": "快捷指令",
5
5
  "main": "index.js",
6
6
  "directories": {
@@ -1,271 +1,331 @@
1
+ #!/usr/bin/env node
1
2
  const fs = require('fs')
2
3
  const path = require('path')
3
4
  const glob = require('glob')
5
+ const ts = require('typescript')
4
6
  const { parse } = require('@vue/compiler-sfc')
5
- const babelParser = require('@babel/parser')
6
- const traverse = require('@babel/traverse').default
7
7
 
8
- const DEFAULT_OPTIONS = {
9
- cwd: process.cwd(),
10
- scanDirs: ['src/views', 'src/components'],
11
- silent: false,
12
- }
8
+ const ROOT = process.cwd()
9
+ const SCAN_DIRS = ['src/views'] // components 先预留
10
+ const FILE_EXT = ['vue', 'ts', 'js', 'tsx', 'jsx']
11
+
12
+ // 匹配 t() 调用:t('key') 或 t('key', 'default')
13
+ const T_FUNCTION_REG = /\bt\s*\(\s*['"`]([^'"`]+)['"`]\s*(?:,\s*['"`][^'"`]*['"`])?\s*\)/g
13
14
 
15
+ /**
16
+ * 扁平化 locale 对象,返回所有 key 的集合
17
+ */
14
18
  function flattenLocale(obj, prefix = '') {
15
- let res = []
19
+ const keys = new Set()
16
20
  for (const k in obj) {
17
21
  const key = prefix ? `${prefix}.${k}` : k
18
- if (typeof obj[k] === 'object' && obj[k] !== null) {
19
- res = res.concat(flattenLocale(obj[k], key))
22
+ if (typeof obj[k] === 'object' && obj[k] !== null && !Array.isArray(obj[k])) {
23
+ const nestedKeys = flattenLocale(obj[k], key)
24
+ nestedKeys.forEach(k => keys.add(k))
20
25
  } else {
21
- res.push(key)
26
+ keys.add(key)
22
27
  }
23
28
  }
24
- return res
29
+ return keys
25
30
  }
26
31
 
32
+ /**
33
+ * 加载 locale.ts 文件并提取所有定义的 key
34
+ */
27
35
  function loadLocaleKeys(localePath) {
28
- if (!fs.existsSync(localePath)) return null
29
- const mod = require(localePath)
30
- const localeObj = mod.default || mod
31
- return flattenLocale(localeObj)
32
- }
36
+ if (!fs.existsSync(localePath)) {
37
+ return null
38
+ }
33
39
 
34
- function parseCode(code) {
35
- return babelParser.parse(code, {
36
- sourceType: 'module',
37
- plugins: ['typescript', 'jsx'],
38
- })
40
+ try {
41
+ const code = fs.readFileSync(localePath, 'utf-8')
42
+ const sf = ts.createSourceFile(localePath, code, ts.ScriptTarget.Latest, true)
43
+
44
+ let obj = null
45
+
46
+ function visit(node) {
47
+ if (
48
+ ts.isExportAssignment(node) &&
49
+ ts.isObjectLiteralExpression(node.expression)
50
+ ) {
51
+ try {
52
+ // 使用 eval 来解析对象字面量
53
+ obj = eval(`(${node.expression.getText()})`)
54
+ } catch (e) {
55
+ // 如果解析失败,尝试其他方式
56
+ console.warn(`⚠️ 无法解析 locale 文件: ${localePath}`)
57
+ }
58
+ }
59
+ ts.forEachChild(node, visit)
60
+ }
61
+
62
+ visit(sf)
63
+ return obj ? flattenLocale(obj) : new Set()
64
+ } catch (error) {
65
+ console.warn(`⚠️ 读取 locale 文件失败: ${localePath}`, error.message)
66
+ return null
67
+ }
39
68
  }
40
69
 
41
70
  /**
42
- * components 目录专用:
43
- * 向上查找最近含 locale.ts 的目录(包含自身目录)
44
- * 到达 componentsRoot 停止
71
+ * 从代码中提取所有 t() 调用的 key
45
72
  */
46
- function findNearestLocale(fileDir, componentsRoot) {
47
- let current = fileDir
48
- while (current.startsWith(componentsRoot)) {
49
- const localePath = path.join(current, 'locale.ts')
50
- if (fs.existsSync(localePath)) {
51
- return localePath
52
- }
53
- const parent = path.dirname(current)
54
- if (parent === current) break
55
- current = parent
73
+ function extractTKeys(code) {
74
+ const keys = new Set()
75
+ let match
76
+ while ((match = T_FUNCTION_REG.exec(code)) !== null) {
77
+ keys.add(match[1])
56
78
  }
57
- return null
79
+ return keys
58
80
  }
59
81
 
60
82
  /**
61
- * views 目录专用:
62
- * 只查找当前文件同目录的 locale.ts
83
+ * 检查 locale 导入方式
84
+ * @returns {Object} { hasCorrectImport, hasOtherImport, importPaths }
63
85
  */
64
- function findViewsLocale(fileDir) {
65
- const localePath = path.join(fileDir, 'locale.ts')
66
- if (fs.existsSync(localePath)) return localePath
67
- return null
68
- }
69
-
70
- function run(userOptions = {}) {
71
- const options = { ...DEFAULT_OPTIONS, ...userOptions }
72
- const { cwd, scanDirs, silent } = options
86
+ function checkLocaleImport(code) {
87
+ const result = {
88
+ hasCorrectImport: false,
89
+ hasOtherImport: false,
90
+ importPaths: []
91
+ }
73
92
 
74
- let files = []
93
+ // 匹配 import locale from 'xxx'
94
+ const importReg = /import\s+locale\s+from\s+['"]([^'"]+)['"]/g
95
+ let match
75
96
 
76
- for (const dir of scanDirs) {
77
- const absDir = path.join(cwd, dir)
78
- if (!fs.existsSync(absDir)) {
79
- if (!silent) console.warn(`⚠️ 目录不存在:${dir}`)
80
- continue
97
+ while ((match = importReg.exec(code)) !== null) {
98
+ const importPath = match[1]
99
+ result.importPaths.push(importPath)
100
+ if (importPath === './locale') {
101
+ result.hasCorrectImport = true
102
+ } else {
103
+ result.hasOtherImport = true
81
104
  }
82
- files.push(
83
- ...glob.sync('**/*.{vue,ts,tsx}', {
84
- cwd: absDir,
85
- absolute: true,
86
- })
87
- )
88
105
  }
89
106
 
90
- const missingKeys = []
91
- const noLocaleUsage = []
92
-
93
- const componentsRoot = path.join(cwd, 'src/components')
94
- const viewsRoot = path.join(cwd, 'src/views')
95
-
96
- for (const file of files) {
97
- const content = fs.readFileSync(file, 'utf-8')
98
-
99
- let scriptCode = content
107
+ return result
108
+ }
100
109
 
101
- if (file.endsWith('.vue')) {
102
- const { descriptor } = parse(content)
103
- scriptCode =
104
- descriptor.scriptSetup?.content ||
105
- descriptor.script?.content ||
106
- ''
110
+ /**
111
+ * 解析 Vue 文件,提取 script 部分的代码
112
+ */
113
+ function parseVueFile(filePath) {
114
+ try {
115
+ const content = fs.readFileSync(filePath, 'utf-8')
116
+ const { descriptor } = parse(content)
117
+
118
+ // 合并所有 script 块的内容
119
+ let scriptContent = ''
120
+ if (descriptor.script) {
121
+ scriptContent += descriptor.script.content
107
122
  }
108
-
109
- if (!scriptCode) continue
110
-
111
- let ast
112
- try {
113
- ast = parseCode(scriptCode)
114
- } catch {
115
- continue
123
+ if (descriptor.scriptSetup) {
124
+ scriptContent += '\n' + descriptor.scriptSetup.content
125
+ }
126
+
127
+ // 也检查 template 中的 t() 调用
128
+ if (descriptor.template) {
129
+ scriptContent += '\n' + descriptor.template.content
116
130
  }
117
131
 
118
- const usedKeys = []
119
- let importedLocale = false // 只对 views 生效
132
+ return scriptContent
133
+ } catch (error) {
134
+ console.warn(`⚠️ 解析 Vue 文件失败: ${filePath}`, error.message)
135
+ return ''
136
+ }
137
+ }
120
138
 
121
- traverse(ast, {
122
- ImportDeclaration(p) {
123
- if (p.node.source.value === './locale') {
124
- importedLocale = true
125
- }
126
- },
127
- CallExpression(p) {
128
- const callee = p.node.callee
129
-
130
- if (file.startsWith(viewsRoot)) {
131
- // src/views 目录:只检查 t()
132
- if (
133
- callee.type === 'Identifier' &&
134
- callee.name === 't'
135
- ) {
136
- const keyArg = p.node.arguments[0]
137
- const defaultArg = p.node.arguments[1]
138
- if (keyArg && keyArg.type === 'StringLiteral') {
139
- usedKeys.push({
140
- key: keyArg.value,
141
- defaultValue:
142
- defaultArg && defaultArg.type === 'StringLiteral'
143
- ? defaultArg.value
144
- : null,
145
- })
146
- }
147
- }
148
- } else if (file.startsWith(componentsRoot)) {
149
- // src/components 目录:只检查 $t()
150
- if (
151
- callee.type === 'Identifier' &&
152
- callee.name === '$t'
153
- ) {
154
- const keyArg = p.node.arguments[0]
155
- const defaultArg = p.node.arguments[1]
156
- if (keyArg && keyArg.type === 'StringLiteral') {
157
- usedKeys.push({
158
- key: keyArg.value,
159
- defaultValue:
160
- defaultArg && defaultArg.type === 'StringLiteral'
161
- ? defaultArg.value
162
- : null,
163
- })
164
- }
165
- }
166
- }
167
- },
168
- })
139
+ /**
140
+ * 查找文件对应的 locale.ts 路径
141
+ */
142
+ function findLocalePath(filePath) {
143
+ const dir = path.dirname(filePath)
144
+ const localePath = path.join(dir, 'locale.ts')
145
+ return localePath
146
+ }
169
147
 
170
- if (!usedKeys.length) continue
148
+ /**
149
+ * 扫描单个文件
150
+ */
151
+ function scanFile(filePath, results) {
152
+ let code = ''
153
+
154
+ if (filePath.endsWith('.vue')) {
155
+ code = parseVueFile(filePath)
156
+ } else {
157
+ try {
158
+ code = fs.readFileSync(filePath, 'utf-8')
159
+ } catch (error) {
160
+ console.warn(`⚠️ 读取文件失败: ${filePath}`, error.message)
161
+ return
162
+ }
163
+ }
171
164
 
172
- const fileDir = path.dirname(file)
165
+ if (!code) return
166
+
167
+ // 提取 t() 调用的 key
168
+ const usedKeys = extractTKeys(code)
169
+ if (usedKeys.size === 0) return
170
+
171
+ // 检查 locale 导入方式
172
+ const importInfo = checkLocaleImport(code)
173
+
174
+ // 查找对应的 locale.ts
175
+ const localePath = findLocalePath(filePath)
176
+ const localeKeys = loadLocaleKeys(localePath)
177
+
178
+ const relativePath = path.relative(ROOT, filePath)
179
+ const relativeLocalePath = path.relative(ROOT, localePath)
180
+
181
+ // 如果使用了非标准的导入方式,记录(但不检查 key)
182
+ if (!importInfo.hasCorrectImport && importInfo.hasOtherImport) {
183
+ if (!results.invalidImports.has(relativePath)) {
184
+ results.invalidImports.set(relativePath, {
185
+ file: relativePath,
186
+ importPaths: importInfo.importPaths,
187
+ usedKeys: Array.from(usedKeys)
188
+ })
189
+ }
190
+ return // 非标准导入,不检查 key 是否缺失
191
+ }
173
192
 
174
- if (file.startsWith(viewsRoot)) {
175
- // views 逻辑:只查同目录 locale.ts,且 vue 文件必须导入 locale
176
- const localePath = findViewsLocale(fileDir)
177
- const localeKeys = localePath && loadLocaleKeys(localePath)
193
+ // 如果没有导入 locale,记录(但不检查 key,可能使用全局 locale)
194
+ if (!importInfo.hasCorrectImport && !importInfo.hasOtherImport) {
195
+ if (!results.noLocaleImport.has(relativePath)) {
196
+ results.noLocaleImport.set(relativePath, {
197
+ file: relativePath,
198
+ usedKeys: Array.from(usedKeys)
199
+ })
200
+ }
201
+ return // 没有导入本地 locale,不检查 key 是否缺失
202
+ }
178
203
 
179
- if (file.endsWith('.vue') && !importedLocale) {
180
- noLocaleUsage.push({
181
- file: path.relative(cwd, file),
182
- keys: usedKeys.map(i => i.key),
183
- reason: 'vue 文件未 import locale',
204
+ // 只有使用标准导入方式(import locale from './locale')时,才检查 key
205
+ // 如果 locale.ts 不存在,记录缺失的 key
206
+ if (!localeKeys) {
207
+ usedKeys.forEach(key => {
208
+ if (!results.missingLocale.has(relativePath)) {
209
+ results.missingLocale.set(relativePath, {
210
+ file: relativePath,
211
+ localePath: relativeLocalePath,
212
+ missingKeys: new Set()
184
213
  })
185
- continue
186
214
  }
215
+ results.missingLocale.get(relativePath).missingKeys.add(key)
216
+ })
217
+ return
218
+ }
187
219
 
188
- if (!localeKeys) {
189
- noLocaleUsage.push({
190
- file: path.relative(cwd, file),
191
- keys: usedKeys.map(i => i.key),
192
- reason: '同目录无 locale.ts',
193
- })
194
- continue
195
- }
220
+ // 检查哪些 key 在 locale 中不存在
221
+ const missingKeys = Array.from(usedKeys).filter(key => !localeKeys.has(key))
222
+
223
+ if (missingKeys.length > 0) {
224
+ if (!results.missingKeys.has(relativePath)) {
225
+ results.missingKeys.set(relativePath, {
226
+ file: relativePath,
227
+ localePath: relativeLocalePath,
228
+ missingKeys: new Set()
229
+ })
230
+ }
231
+ missingKeys.forEach(key => {
232
+ results.missingKeys.get(relativePath).missingKeys.add(key)
233
+ })
234
+ }
235
+ }
196
236
 
197
- for (const { key, defaultValue } of usedKeys) {
198
- if (!localeKeys.includes(key)) {
199
- missingKeys.push({
200
- file: path.relative(cwd, file),
201
- key,
202
- defaultValue,
203
- })
204
- }
205
- }
206
- continue
237
+ /**
238
+ * 主函数
239
+ */
240
+ function run() {
241
+ const results = {
242
+ invalidImports: new Map(), // 使用了非 './locale' 的导入
243
+ noLocaleImport: new Map(), // 没有导入 locale 但使用了 t()
244
+ missingLocale: new Map(), // locale.ts 文件不存在
245
+ missingKeys: new Map() // locale.ts 存在但缺少某些 key
246
+ }
247
+
248
+ // 扫描所有文件
249
+ SCAN_DIRS.forEach(dir => {
250
+ const absDir = path.join(ROOT, dir)
251
+ if (!fs.existsSync(absDir)) {
252
+ console.log(`⚠️ 目录不存在: ${dir}`)
253
+ return
207
254
  }
208
255
 
209
- if (file.startsWith(componentsRoot)) {
210
- // components 逻辑:向上找最近 locale.ts
211
- const localePath = findNearestLocale(fileDir, componentsRoot)
212
- const localeKeys = localePath && loadLocaleKeys(localePath)
256
+ FILE_EXT.forEach(ext => {
257
+ const pattern = `${absDir}/**/*.${ext}`
258
+ const files = glob.sync(pattern, { nodir: true })
259
+ files.forEach(file => scanFile(file, results))
260
+ })
261
+ })
213
262
 
214
- if (!localeKeys) {
215
- noLocaleUsage.push({
216
- file: path.relative(cwd, file),
217
- keys: usedKeys.map(i => i.key),
218
- reason: '未找到可用 locale.ts',
219
- })
220
- continue
221
- }
263
+ // 输出结果
264
+ console.log('\n📋 i18n 使用检查结果\n')
222
265
 
223
- for (const { key, defaultValue } of usedKeys) {
224
- if (!localeKeys.includes(key)) {
225
- missingKeys.push({
226
- file: path.relative(cwd, file),
227
- key,
228
- defaultValue,
229
- })
230
- }
266
+ let hasError = false
267
+
268
+ // 1. 输出非标准导入方式
269
+ if (results.invalidImports.size > 0) {
270
+ hasError = true
271
+ console.log('❌ 使用了非标准的 locale 导入方式(应使用 import locale from \'./locale\'):\n')
272
+ results.invalidImports.forEach((info, file) => {
273
+ console.log(` ${file}`)
274
+ info.importPaths.forEach(importPath => {
275
+ console.log(` ↳ import locale from '${importPath}'`)
276
+ })
277
+ if (info.usedKeys && info.usedKeys.length > 0) {
278
+ console.log(` 使用的 key: ${info.usedKeys.join(', ')}`)
231
279
  }
232
- continue
233
- }
280
+ })
281
+ console.log()
234
282
  }
235
283
 
236
- if (!silent) {
237
- if (missingKeys.length) {
238
- console.error('\n❌ 缺失的国际化 key:')
239
- missingKeys.forEach(i =>
240
- console.error(
241
- ` ${i.file} → ${i.key}${
242
- i.defaultValue ? ` (默认值: ${i.defaultValue})` : ''
243
- }`
244
- )
245
- )
246
- }
284
+ // 2. 输出没有导入 locale 的文件
285
+ if (results.noLocaleImport.size > 0) {
286
+ console.log('⚠️ 以下文件使用了 t() 但未导入本地 locale(可能使用全局 locale):\n')
287
+ results.noLocaleImport.forEach((info, file) => {
288
+ console.log(` ${file}`)
289
+ console.log(` 使用的 key: ${info.usedKeys.join(', ')}`)
290
+ })
291
+ console.log()
292
+ }
247
293
 
248
- if (noLocaleUsage.length) {
249
- console.warn('\n⚠️ 以下文件使用了 $t / t,但未正确使用 locale.ts:')
250
- noLocaleUsage.forEach(i =>
251
- console.warn(
252
- ` ${i.file} → 原因: ${i.reason},涉及 keys: ${i.keys.join(', ')}`
253
- )
254
- )
255
- }
294
+ // 3. 输出缺少 locale.ts 文件的情况
295
+ if (results.missingLocale.size > 0) {
296
+ hasError = true
297
+ console.log('❌ 以下文件使用了 t() 但缺少对应的 locale.ts 文件:\n')
298
+ results.missingLocale.forEach((info, file) => {
299
+ console.log(` ${file}`)
300
+ console.log(` 期望的 locale 文件: ${info.localePath}`)
301
+ console.log(` 缺失的 key: ${Array.from(info.missingKeys).join(', ')}`)
302
+ })
303
+ console.log()
304
+ }
256
305
 
257
- if (!missingKeys.length && !noLocaleUsage.length) {
258
- console.log('✅ 国际化检查通过(views + components)')
259
- }
306
+ // 4. 输出缺少的 key
307
+ if (results.missingKeys.size > 0) {
308
+ hasError = true
309
+ console.log('❌ 以下文件使用的 key 在 locale.ts 中未定义:\n')
310
+ results.missingKeys.forEach((info, file) => {
311
+ console.log(` ${file}`)
312
+ console.log(` locale 文件: ${info.localePath}`)
313
+ console.log(` 缺失的 key:`)
314
+ Array.from(info.missingKeys).forEach(key => {
315
+ console.log(` - ${key}`)
316
+ })
317
+ })
318
+ console.log()
260
319
  }
261
320
 
262
- return {
263
- success: missingKeys.length === 0,
264
- missingKeys,
265
- noLocaleUsage,
266
- scannedFiles: files.length,
267
- options,
321
+ // 总结
322
+ if (hasError) {
323
+ console.log('❗ 检查未通过,请修复上述问题')
324
+ process.exitCode = 1
325
+ } else {
326
+ console.log('✅ 所有检查通过')
268
327
  }
269
328
  }
270
329
 
271
330
  exports.run = run
331
+