eslint-plugin-smarthr 6.20.0 → 6.21.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ## [6.21.1](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v6.21.0...eslint-plugin-smarthr-v6.21.1) (2026-06-22)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **eslint-plugin-smarthr:** use https repository metadata ([#1407](https://github.com/kufu/tamatebako/issues/1407)) ([e6d3d82](https://github.com/kufu/tamatebako/commit/e6d3d82d4d833a2716c67d91f82fe7bd6e2ab6cc))
11
+
12
+ ## [6.21.0](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v6.20.0...eslint-plugin-smarthr-v6.21.0) (2026-06-22)
13
+
14
+
15
+ ### Features
16
+
17
+ * require-i18n-translation-sync ESLintルールを追加 ([#1406](https://github.com/kufu/tamatebako/issues/1406)) ([5286bbb](https://github.com/kufu/tamatebako/commit/5286bbb94c6b774f9132f6d40ef36c216cff0399))
18
+
5
19
  ## [6.20.0](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v6.19.0...eslint-plugin-smarthr-v6.20.0) (2026-06-16)
6
20
 
7
21
 
package/README.md CHANGED
@@ -57,5 +57,6 @@
57
57
  - [require-declaration](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/require-declaration)
58
58
  - [require-export](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/require-export)
59
59
  - [require-i18n-text](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/require-i18n-text)
60
+ - [require-i18n-translation-sync](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/require-i18n-translation-sync)
60
61
  - [require-import](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/require-import)
61
62
  - [trim-props](https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/trim-props)
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "eslint-plugin-smarthr",
3
- "version": "6.20.0",
3
+ "version": "6.21.1",
4
4
  "author": "SmartHR",
5
5
  "license": "MIT",
6
6
  "description": "A sharable ESLint plugin for SmartHR",
7
7
  "main": "index.js",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+git@github.com:kufu/tamatebako.git"
10
+ "url": "git+https://github.com/kufu/tamatebako.git"
11
11
  },
12
12
  "homepage": "https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr",
13
13
  "publishConfig": {
@@ -205,6 +205,58 @@ useEffect(() => {
205
205
 
206
206
  **注意:** メモ化は依存関係が明確な場合に有効です。依存関係が不明確な場合は、方法1のrefを使用することを推奨します。
207
207
 
208
+ ### 方法5: イベントオブジェクトから値を取得する(useCallbackの場合)
209
+
210
+ useCallbackでイベントハンドラーを定義する場合、値を依存配列に含めずに、イベントオブジェクトから取得できます。
211
+
212
+ **button要素の`value`属性を使う:**
213
+
214
+ ```javascript
215
+ // ❌ 値を依存配列に含める
216
+ const handleClick = useCallback(() => {
217
+ doSomething(itemId)
218
+ }, [itemId]) // itemIdが変わるたびにコールバックが再生成される
219
+
220
+ <button onClick={handleClick}>削除</button>
221
+ ```
222
+
223
+ ```javascript
224
+ // ✅ イベントから値を取得
225
+ const handleClick = useCallback((e) => {
226
+ const itemId = e.currentTarget.value
227
+ doSomething(itemId)
228
+ }, []) // 依存配列が空になり、コールバックが安定する
229
+
230
+ <button value={itemId} onClick={handleClick}>削除</button>
231
+ ```
232
+
233
+ **data-*属性も同様に使える:**
234
+
235
+ ```javascript
236
+ const handleClick = useCallback((e) => {
237
+ const itemId = e.currentTarget.getAttribute('data-id')
238
+ const itemType = e.currentTarget.getAttribute('data-type')
239
+ doSomething(itemId, itemType)
240
+ }, [])
241
+
242
+ <button
243
+ data-id={itemId}
244
+ data-type={itemType}
245
+ onClick={handleClick}
246
+ >
247
+ 削除
248
+ </button>
249
+ ```
250
+
251
+ **メリット:**
252
+ - 依存配列が空になり、コールバックが安定する
253
+ - コンポーネントの再レンダリングが減る
254
+ - リスト内の各アイテムで異なるコールバック関数を生成する必要がない
255
+
256
+ **注意:**
257
+ - この方法はイベントハンドラー(onClick、onChange等)でのみ使用できます。useEffect内など、イベントオブジェクトがない場合は他の方法を使用してください。
258
+ - `value`属性や`getAttribute()`で取得した値は**文字列**になります。数値や真偽値が必要な場合は適切に変換してください(例: `Number(e.currentTarget.value)`、`e.currentTarget.value === 'true'`)。
259
+
208
260
  ## オプション
209
261
 
210
262
  ### additionalUnstableNames
@@ -0,0 +1,365 @@
1
+ # require-i18n-translation-sync
2
+
3
+ TypeScript翻訳ファイル(ja.ts)とJSON翻訳ファイル(ja.json)の同期を保証するルールです。
4
+
5
+ ## 背景
6
+
7
+ 多言語翻訳の仕組みでは以下の要件があります:
8
+
9
+ 1. **TypeScript形式が必要な理由**
10
+ - 翻訳キーの型定義により、存在しないキーの使用を静的解析でエラーに
11
+ - FormattedMessageの`defaultMessage`が翻訳ファイルの値と一致することを型で保証
12
+
13
+ 2. **JSON形式が必要な理由**
14
+ - 全言語でキー構造を揃える必要がある(翻訳ツールの仕様)
15
+ - 翻訳ツールとの互換性
16
+
17
+ 3. **SSOT(Single Source of Truth)の原則**
18
+ - ja.ts と ja.json を別々に定義すると内容の食い違いが発生
19
+ - ja.ts をソースとして、ja.json を自動生成する
20
+
21
+ このルールは、ja.ts と ja.json の内容が一致することを保証し、不一致があれば自動修正します。
22
+
23
+ ## チェック内容
24
+
25
+ ### 1. オブジェクトリテラルのexportは1つのみ
26
+
27
+ 翻訳ファイルは必ず1つのオブジェクトリテラルをexportする必要があります。型定義など、オブジェクトリテラル以外のexportは複数あっても問題ありません。
28
+
29
+ ```typescript
30
+ // ❌ NG: オブジェクトリテラルが複数
31
+ export const translations = { ... }
32
+ export const other = { ... }
33
+
34
+ // ✅ OK: オブジェクトリテラルは1つのみ
35
+ export const translations = { ... }
36
+
37
+ // ✅ OK: 型定義とオブジェクトリテラル
38
+ export type TranslationKeys = keyof typeof translations
39
+ export const translations = { ... }
40
+ ```
41
+
42
+ ### 2. フラットな構造
43
+
44
+ 翻訳ファイルはフラットなオブジェクト構造である必要があります。
45
+
46
+ ```typescript
47
+ // ❌ NG: ネストされたオブジェクト
48
+ export const translations = {
49
+ Common: {
50
+ Button: {
51
+ Submit: '送信'
52
+ }
53
+ }
54
+ }
55
+
56
+ // ✅ OK: フラットな構造
57
+ export const translations = {
58
+ 'Common/Button/Submit': '送信'
59
+ }
60
+ ```
61
+
62
+ ### 3. 値は文字列のみ
63
+
64
+ オブジェクトの値はすべて文字列である必要があります。
65
+
66
+ ```typescript
67
+ // ❌ NG: 数値、真偽値
68
+ export const translations = {
69
+ count: 123,
70
+ enabled: true
71
+ }
72
+
73
+ // ✅ OK: 文字列のみ
74
+ export const translations = {
75
+ count: '123',
76
+ enabled: 'true'
77
+ }
78
+ ```
79
+
80
+ ### 4. JSONとの同期
81
+
82
+ ja.ts と ja.json の内容が一致する必要があります。不一致の場合、autofixで ja.json を自動生成します。
83
+
84
+ ## エラーケース
85
+
86
+ ### オブジェクトリテラルが複数
87
+
88
+ ```typescript
89
+ // ❌ NG
90
+ export const translations = { key1: 'value1' }
91
+ export const other = { key2: 'value2' }
92
+ ```
93
+
94
+ **エラー:** 翻訳ファイルはオブジェクトリテラルを1つのみexportする必要があります。
95
+
96
+ ### オブジェクトリテラルのexportがない
97
+
98
+ ```typescript
99
+ // ❌ NG: exportがない
100
+ const translations = { key1: 'value1' }
101
+
102
+ // ❌ NG: オブジェクトリテラル以外をexport
103
+ export const translations = 'not an object'
104
+ export const translations = ['array']
105
+
106
+ // ❌ NG: 型のみをexport
107
+ export type TranslationKeys = string
108
+ ```
109
+
110
+ **エラー:** 翻訳ファイルはオブジェクトリテラルをexportする必要があります。
111
+
112
+ ### 値が文字列以外
113
+
114
+ ```typescript
115
+ // ❌ NG
116
+ export const translations = {
117
+ count: 123,
118
+ enabled: true,
119
+ value: null
120
+ }
121
+ ```
122
+
123
+ **エラー:** キー "count" の値は文字列である必要があります。
124
+
125
+ ### ネストされたオブジェクト
126
+
127
+ ```typescript
128
+ // ❌ NG
129
+ export const translations = {
130
+ Common: {
131
+ Button: 'ボタン'
132
+ }
133
+ }
134
+ ```
135
+
136
+ **エラー:** キー "Common" の値はネストされたオブジェクトです。翻訳ファイルはフラットな構造である必要があります。
137
+
138
+ ### スプレッド構文
139
+
140
+ ```typescript
141
+ // ❌ NG
142
+ const base = { key1: 'value1' }
143
+ export const translations = { ...base, key2: 'value2' }
144
+ ```
145
+
146
+ **エラー:** スプレッド構文は使用できません。
147
+
148
+ ### テンプレートリテラルに式を含む
149
+
150
+ ```typescript
151
+ // ❌ NG
152
+ export const translations = {
153
+ message: `Hello ${name}`
154
+ }
155
+ ```
156
+
157
+ **エラー:** テンプレートリテラルに式を含めることはできません。
158
+
159
+ ### ja.ts と ja.json が不一致
160
+
161
+ ```typescript
162
+ // ja.ts
163
+ export const translations = { key1: 'new value' }
164
+
165
+ // ja.json(古い内容)
166
+ {
167
+ "key1": "old value"
168
+ }
169
+ ```
170
+
171
+ **エラー:** 翻訳ファイル(ja.ts)とJSONファイル(ja.json)の内容が一致しません。autofixで同期できます。
172
+
173
+ **修正:** ESLintの`--fix`オプションを使用すると、ja.json が自動的に更新されます。
174
+
175
+ ## OK例
176
+
177
+ ### export const
178
+
179
+ ```typescript
180
+ export const translations = {
181
+ 'Common/Button/Submit': '送信',
182
+ 'Common/Button/Cancel': 'キャンセル'
183
+ }
184
+ ```
185
+
186
+ ### export default
187
+
188
+ ```typescript
189
+ export default {
190
+ 'Common/Button/Submit': '送信',
191
+ 'Common/Button/Cancel': 'キャンセル'
192
+ }
193
+ ```
194
+
195
+ ### 型アサーション
196
+
197
+ ```typescript
198
+ export const translations = {
199
+ 'Common/Button/Submit': '送信'
200
+ } as const
201
+ ```
202
+
203
+ ### 型注釈
204
+
205
+ ```typescript
206
+ export const translations: Record<string, string> = {
207
+ 'Common/Button/Submit': '送信'
208
+ }
209
+ ```
210
+
211
+ ### テンプレートリテラル(式なし)
212
+
213
+ ```typescript
214
+ export const translations = {
215
+ message: `Hello`
216
+ }
217
+ ```
218
+
219
+ ### Identifierキー
220
+
221
+ ```typescript
222
+ export const translations = {
223
+ submit: '送信',
224
+ cancel: 'キャンセル'
225
+ }
226
+ ```
227
+
228
+ ### 型定義との併用
229
+
230
+ ```typescript
231
+ export type TranslationKeys = keyof typeof translations
232
+
233
+ export const translations = {
234
+ 'Common/Button/Submit': '送信',
235
+ 'Common/Button/Cancel': 'キャンセル'
236
+ }
237
+ ```
238
+
239
+ ## オプション
240
+
241
+ ### targetFileName
242
+
243
+ 対象とするファイル名を指定します。デフォルトは`ja.ts`です。
244
+
245
+ ```javascript
246
+ {
247
+ 'smarthr/require-i18n-translation-sync': ['error', {
248
+ targetFileName: 'en_us.ts'
249
+ }]
250
+ }
251
+ ```
252
+
253
+ ### indent
254
+
255
+ JSON出力時のインデント幅を指定します。デフォルトは`2`(2スペース)です。
256
+
257
+ ```javascript
258
+ {
259
+ 'smarthr/require-i18n-translation-sync': ['error', {
260
+ indent: 4
261
+ }]
262
+ }
263
+ ```
264
+
265
+ ### endOfLine
266
+
267
+ JSON出力時の改行コードを指定します。デフォルトは`lf`です。
268
+
269
+ - `lf`: LF(`\n`)Unix/Linux/macOS標準
270
+ - `crlf`: CRLF(`\r\n`)Windows標準
271
+
272
+ ```javascript
273
+ {
274
+ 'smarthr/require-i18n-translation-sync': ['error', {
275
+ endOfLine: 'crlf'
276
+ }]
277
+ }
278
+ ```
279
+
280
+ ## 使用例
281
+
282
+ ### 推奨: 対象ファイルのみに適用(効率的)
283
+
284
+ **このルールは対象ファイル(デフォルト: ja.ts)のみをチェックするため、全ファイルに対して実行すると無駄なチェックが発生します。**
285
+
286
+ ESLintの `files` オプションで対象ファイルを指定することで、効率的にチェックできます。
287
+
288
+ ```javascript
289
+ export default [
290
+ {
291
+ files: ['**/ja.ts'],
292
+ rules: {
293
+ 'smarthr/require-i18n-translation-sync': 'error'
294
+ }
295
+ }
296
+ ]
297
+ ```
298
+
299
+ ### 基本的な使用(非推奨)
300
+
301
+ 全ファイルに対してルールを有効化する場合(対象外ファイルは内部でスキップされますが効率が悪いです):
302
+
303
+ ```javascript
304
+ {
305
+ 'smarthr/require-i18n-translation-sync': 'error'
306
+ }
307
+ ```
308
+
309
+ ### カスタム設定
310
+
311
+ ```javascript
312
+ export default [
313
+ {
314
+ files: ['**/ja.ts'],
315
+ rules: {
316
+ 'smarthr/require-i18n-translation-sync': ['error', {
317
+ targetFileName: 'ja.ts',
318
+ indent: 2,
319
+ endOfLine: 'lf'
320
+ }]
321
+ }
322
+ }
323
+ ]
324
+ ```
325
+
326
+ ### 複数の言語ファイルに対応
327
+
328
+ 英語の翻訳ファイルも同様にチェックする場合:
329
+
330
+ ```javascript
331
+ export default [
332
+ {
333
+ files: ['**/ja.ts'],
334
+ rules: {
335
+ 'smarthr/require-i18n-translation-sync': ['error', {
336
+ targetFileName: 'ja.ts'
337
+ }]
338
+ }
339
+ },
340
+ {
341
+ files: ['**/en.ts'],
342
+ rules: {
343
+ 'smarthr/require-i18n-translation-sync': ['error', {
344
+ targetFileName: 'en.ts'
345
+ }]
346
+ }
347
+ }
348
+ ]
349
+ ```
350
+
351
+ ## 自動修正
352
+
353
+ このルールは`--fix`オプションで自動修正できます。
354
+
355
+ ```bash
356
+ eslint --fix path/to/ja.ts
357
+ ```
358
+
359
+ 自動修正により、ja.ts から抽出した翻訳データが ja.json に書き込まれます。
360
+
361
+ ## 注意事項
362
+
363
+ - このルールは指定されたファイル名(デフォルト: ja.ts)にのみ適用されます
364
+ - JSONファイルのキー順序は、TypeScriptファイルの記述順を保持します
365
+ - 他言語ファイル(en.json, zh.json など)は翻訳ツールで管理し、開発者が直接編集することは想定していません
@@ -0,0 +1,277 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+
4
+ const SCHEMA = [
5
+ {
6
+ type: 'object',
7
+ properties: {
8
+ targetFileName: {
9
+ type: 'string',
10
+ default: 'ja.ts',
11
+ },
12
+ indent: {
13
+ type: 'number',
14
+ default: 2,
15
+ },
16
+ endOfLine: {
17
+ type: 'string',
18
+ enum: ['lf', 'crlf'],
19
+ default: 'lf',
20
+ },
21
+ },
22
+ additionalProperties: false,
23
+ },
24
+ ]
25
+
26
+ /**
27
+ * オブジェクトリテラルから翻訳データを抽出
28
+ * @param {import('estree').ObjectExpression} node
29
+ * @param {import('@typescript-eslint/utils').TSESLint.SourceCode} sourceCode
30
+ * @returns {{ translations: Record<string, string>, errors: Array<{ node: any, message: string }> }}
31
+ */
32
+ function extractTranslations(node, sourceCode) {
33
+ const translations = {}
34
+ const errors = []
35
+
36
+ for (const prop of node.properties) {
37
+ // SpreadElementは不許可
38
+ if (prop.type === 'SpreadElement') {
39
+ errors.push({
40
+ node: prop,
41
+ message: 'スプレッド構文は使用できません。翻訳ファイルはフラットなオブジェクトである必要があります。',
42
+ })
43
+ continue
44
+ }
45
+
46
+ // キーの取得
47
+ let key
48
+ switch (prop.key.type) {
49
+ case 'Identifier':
50
+ key = prop.key.name
51
+ break
52
+ case 'Literal':
53
+ key = String(prop.key.value)
54
+ break
55
+ default:
56
+ errors.push({
57
+ node: prop.key,
58
+ message: 'キーは文字列リテラルまたは識別子である必要があります。',
59
+ })
60
+ continue
61
+ }
62
+
63
+ // 値の検証
64
+ switch (prop.value.type) {
65
+ case 'Literal':
66
+ if (typeof prop.value.value !== 'string') {
67
+ errors.push({
68
+ node: prop.value,
69
+ message: `キー "${key}" の値は文字列である必要があります。数値、真偽値などは使用できません。`,
70
+ })
71
+ continue
72
+ }
73
+ translations[key] = prop.value.value
74
+ break
75
+ case 'TemplateLiteral':
76
+ // テンプレートリテラルで式がない場合のみ許可
77
+ if (prop.value.expressions.length === 0) {
78
+ translations[key] = prop.value.quasis[0].value.cooked
79
+ } else {
80
+ errors.push({
81
+ node: prop.value,
82
+ message: `キー "${key}" の値は静的な文字列である必要があります。テンプレートリテラルに式を含めることはできません。`,
83
+ })
84
+ }
85
+ break
86
+ case 'ObjectExpression':
87
+ errors.push({
88
+ node: prop.value,
89
+ message: `キー "${key}" の値はネストされたオブジェクトです。翻訳ファイルはフラットな構造である必要があります。`,
90
+ })
91
+ break
92
+ default:
93
+ errors.push({
94
+ node: prop.value,
95
+ message: `キー "${key}" の値は文字列リテラルである必要があります。`,
96
+ })
97
+ }
98
+ }
99
+
100
+ return { translations, errors }
101
+ }
102
+
103
+ /**
104
+ * export文からオブジェクトを探す
105
+ * @param {import('estree').Program} program
106
+ * @returns {{ objectExportCount: number, exportedObject: import('estree').ObjectExpression | null }}
107
+ */
108
+ function findExportedObject(program) {
109
+ let objectExportCount = 0
110
+ let exportedObject = null
111
+
112
+ for (const node of program.body) {
113
+ // export const translations = { ... }
114
+ if (node.type === 'ExportNamedDeclaration' && node.declaration && node.declaration.type === 'VariableDeclaration') {
115
+ for (const decl of node.declaration.declarations) {
116
+ if (decl.init) {
117
+ switch (decl.init.type) {
118
+ case 'ObjectExpression':
119
+ objectExportCount++
120
+ exportedObject = decl.init
121
+ break
122
+ case 'TSAsExpression':
123
+ if (decl.init.expression.type === 'ObjectExpression') {
124
+ // as const などの型アサーション
125
+ objectExportCount++
126
+ exportedObject = decl.init.expression
127
+ }
128
+ break
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ // export default { ... }
135
+ if (node.type === 'ExportDefaultDeclaration') {
136
+ switch (node.declaration.type) {
137
+ case 'ObjectExpression':
138
+ objectExportCount++
139
+ exportedObject = node.declaration
140
+ break
141
+ case 'TSAsExpression':
142
+ if (node.declaration.expression.type === 'ObjectExpression') {
143
+ objectExportCount++
144
+ exportedObject = node.declaration.expression
145
+ }
146
+ break
147
+ }
148
+ }
149
+ }
150
+
151
+ return { objectExportCount, exportedObject }
152
+ }
153
+
154
+ /**
155
+ * @type {import('@typescript-eslint/utils').TSESLint.RuleModule<'multipleExports' | 'noExport' | 'notObject' | 'invalidValue' | 'notSync'>}
156
+ */
157
+ module.exports = {
158
+ meta: {
159
+ type: 'problem',
160
+ fixable: 'code',
161
+ schema: SCHEMA,
162
+ messages: {
163
+ multipleExports: '翻訳ファイルはオブジェクトリテラルを1つのみexportする必要があります。現在{{count}}個のオブジェクトがexportされています。',
164
+ noExport: '翻訳ファイルはオブジェクトリテラルをexportする必要があります。',
165
+ notObject: 'exportする値はオブジェクトリテラルである必要があります。',
166
+ invalidValue: '{{message}}',
167
+ notSync: '翻訳ファイル({{tsFile}})とJSONファイル({{jsonFile}})の内容が一致しません。autofixで同期できます。',
168
+ },
169
+ },
170
+ create(context) {
171
+ const options = context.options[0] || {}
172
+ const targetFileName = options.targetFileName || 'ja.ts'
173
+
174
+ const filename = context.getFilename()
175
+ const basename = path.basename(filename)
176
+
177
+ // 対象ファイル以外はスキップ
178
+ if (basename !== targetFileName) {
179
+ return {}
180
+ }
181
+
182
+ return {
183
+ Program(node) {
184
+ const { objectExportCount, exportedObject } = findExportedObject(node)
185
+
186
+ // オブジェクトのexportが複数ある場合
187
+ if (objectExportCount > 1) {
188
+ context.report({
189
+ node,
190
+ messageId: 'multipleExports',
191
+ data: { count: objectExportCount },
192
+ })
193
+ return
194
+ }
195
+
196
+ // オブジェクトのexportがない場合
197
+ if (objectExportCount === 0) {
198
+ context.report({
199
+ node,
200
+ messageId: 'noExport',
201
+ })
202
+ return
203
+ }
204
+
205
+ // exportがオブジェクトでない場合(念のためのチェック)
206
+ if (!exportedObject) {
207
+ context.report({
208
+ node,
209
+ messageId: 'notObject',
210
+ })
211
+ return
212
+ }
213
+
214
+ // オブジェクトから翻訳データを抽出
215
+ const sourceCode = context.getSourceCode()
216
+ const { translations, errors } = extractTranslations(exportedObject, sourceCode)
217
+
218
+ // 構造エラーを報告
219
+ for (const error of errors) {
220
+ context.report({
221
+ node: error.node,
222
+ messageId: 'invalidValue',
223
+ data: { message: error.message },
224
+ })
225
+ }
226
+
227
+ // エラーがある場合はJSONとの比較をスキップ
228
+ if (errors.length > 0) {
229
+ return
230
+ }
231
+
232
+ // JSONファイルのパス
233
+ const tsFilePath = filename
234
+ const parsed = path.parse(tsFilePath)
235
+ const jsonFilePath = path.join(parsed.dir, parsed.name + '.json')
236
+
237
+ // JSON文字列を生成
238
+ const indent = options.indent || 2
239
+ const endOfLine = options.endOfLine || 'lf'
240
+ const newline = endOfLine === 'crlf' ? '\r\n' : '\n'
241
+ const jsonString = JSON.stringify(translations, null, indent) + newline
242
+
243
+ // JSONファイルを読み込んで比較
244
+ let existingJsonString = ''
245
+ try {
246
+ if (fs.existsSync(jsonFilePath)) {
247
+ existingJsonString = fs.readFileSync(jsonFilePath, 'utf8')
248
+ }
249
+ } catch (error) {
250
+ // ファイル読み込みエラーは無視(新規作成として扱う)
251
+ }
252
+
253
+ // 内容が一致しない場合
254
+ if (existingJsonString !== jsonString) {
255
+ const tsFile = path.basename(tsFilePath)
256
+ const jsonFile = path.basename(jsonFilePath)
257
+
258
+ context.report({
259
+ node,
260
+ messageId: 'notSync',
261
+ data: { tsFile, jsonFile },
262
+ fix(fixer) {
263
+ // autofixer: JSONファイルを書き込む
264
+ // ESLintのfixerはファイル書き込みをサポートしないため、
265
+ // ここでは直接ファイルを書き込む
266
+ fs.writeFileSync(jsonFilePath, jsonString, 'utf8')
267
+ // fixerは何も返さない(ファイル外の変更のため)
268
+ return []
269
+ },
270
+ })
271
+ }
272
+ },
273
+ }
274
+ },
275
+ }
276
+
277
+ module.exports.schema = SCHEMA
@@ -0,0 +1,309 @@
1
+ const fs = require('fs')
2
+ const os = require('os')
3
+ const path = require('path')
4
+
5
+ const rule = require('../rules/require-i18n-translation-sync')
6
+ const RuleTester = require('eslint').RuleTester
7
+
8
+ // テスト用の一時ディレクトリ
9
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-sync-test-'))
10
+
11
+ /**
12
+ * テスト用のJSONファイルを作成
13
+ */
14
+ function setupTestFile(basename, translations, options = {}) {
15
+ const tsPath = path.join(tmpDir, basename)
16
+ const parsed = path.parse(basename)
17
+ const jsonPath = path.join(tmpDir, parsed.name + '.json')
18
+
19
+ // JSONファイルを作成
20
+ const indent = options.indent || 2
21
+ const endOfLine = options.endOfLine || 'lf'
22
+ const newline = endOfLine === 'crlf' ? '\r\n' : '\n'
23
+ const jsonString = JSON.stringify(translations, null, indent) + newline
24
+
25
+ fs.writeFileSync(jsonPath, jsonString, 'utf8')
26
+
27
+ return tsPath
28
+ }
29
+
30
+ /**
31
+ * テスト用のファイルパスを生成(JSONなし)
32
+ */
33
+ function createTestPath(basename) {
34
+ return path.join(tmpDir, basename)
35
+ }
36
+
37
+ const ruleTester = new RuleTester({
38
+ languageOptions: {
39
+ parser: require('@typescript-eslint/parser'),
40
+ parserOptions: {
41
+ ecmaVersion: 2020,
42
+ sourceType: 'module',
43
+ },
44
+ },
45
+ })
46
+
47
+ const defaultOptions = [{}]
48
+
49
+ ruleTester.run('require-i18n-translation-sync', rule, {
50
+ valid: [
51
+ // 対象ファイル名でない場合はスキップ
52
+ {
53
+ code: "export const translations = { key1: 'value1' }",
54
+ filename: createTestPath('en.ts'),
55
+ options: defaultOptions,
56
+ },
57
+
58
+ // ja.ts と ja.json が一致している場合
59
+ {
60
+ code: "export const translations = { key1: 'value1', key2: 'value2' }",
61
+ filename: setupTestFile('valid-sync.ts', { key1: 'value1', key2: 'value2' }),
62
+ options: defaultOptions,
63
+ },
64
+
65
+ // export default でも OK
66
+ {
67
+ code: "export default { key1: 'value1' }",
68
+ filename: setupTestFile('valid-default.ts', { key1: 'value1' }),
69
+ options: defaultOptions,
70
+ },
71
+
72
+ // Identifierキーでも OK
73
+ {
74
+ code: "export const translations = { key1: 'value1', key2: 'value2' }",
75
+ filename: setupTestFile('valid-identifier.ts', { key1: 'value1', key2: 'value2' }),
76
+ options: defaultOptions,
77
+ },
78
+
79
+ // テンプレートリテラル(式なし)
80
+ {
81
+ code: 'export const translations = { key1: `value1` }',
82
+ filename: setupTestFile('valid-template.ts', { key1: 'value1' }),
83
+ options: defaultOptions,
84
+ },
85
+
86
+ // カスタムファイル名
87
+ {
88
+ code: "export const translations = { key1: 'value1' }",
89
+ filename: setupTestFile('en_us.ts', { key1: 'value1' }),
90
+ options: [{ targetFileName: 'en_us.ts' }],
91
+ },
92
+
93
+ // カスタムファイル名: .js拡張子
94
+ {
95
+ code: "export const translations = { key1: 'value1' }",
96
+ filename: setupTestFile('custom.js', { key1: 'value1' }),
97
+ options: [{ targetFileName: 'custom.js' }],
98
+ },
99
+
100
+ // カスタムファイル名: .tsx拡張子
101
+ {
102
+ code: "export const translations = { key1: 'value1' }",
103
+ filename: setupTestFile('custom.tsx', { key1: 'value1' }),
104
+ options: [{ targetFileName: 'custom.tsx' }],
105
+ },
106
+
107
+ // カスタムファイル名: .jsx拡張子
108
+ {
109
+ code: "export const translations = { key1: 'value1' }",
110
+ filename: setupTestFile('custom.jsx', { key1: 'value1' }),
111
+ options: [{ targetFileName: 'custom.jsx' }],
112
+ },
113
+
114
+ // エスケープ: ダブルクォートを含む(シングルクォート文字列)
115
+ {
116
+ code: "export const translations = { key1: 'hogefuga\"piyo\".' }",
117
+ filename: setupTestFile('valid-escape-double.ts', { key1: 'hogefuga"piyo".' }),
118
+ options: defaultOptions,
119
+ },
120
+
121
+ // エスケープ: シングルクォートを含む(ダブルクォート文字列)
122
+ {
123
+ code: "export const translations = { key1: \"hoge'fuga'.\" }",
124
+ filename: setupTestFile('valid-escape-single.ts', { key1: "hoge'fuga'." }),
125
+ options: defaultOptions,
126
+ },
127
+
128
+ // エスケープ: バックスラッシュを含む
129
+ {
130
+ code: "export const translations = { key1: '円記号\\\\あり' }",
131
+ filename: setupTestFile('valid-escape-backslash.ts', { key1: '円記号\\あり' }),
132
+ options: defaultOptions,
133
+ },
134
+
135
+ // エスケープ: 改行を含む
136
+ {
137
+ code: "export const translations = { key1: '改行\\nあり' }",
138
+ filename: setupTestFile('valid-escape-newline.ts', { key1: '改行\nあり' }),
139
+ options: defaultOptions,
140
+ },
141
+
142
+ // エスケープ: タブを含む
143
+ {
144
+ code: "export const translations = { key1: 'タブ\\tあり' }",
145
+ filename: setupTestFile('valid-escape-tab.ts', { key1: 'タブ\tあり' }),
146
+ options: defaultOptions,
147
+ },
148
+
149
+ // 特殊パターン: プレースホルダーを含む
150
+ {
151
+ code: "export const translations = { greeting: 'こんにちは、{name}さん' }",
152
+ filename: setupTestFile('valid-placeholder.ts', { greeting: 'こんにちは、{name}さん' }),
153
+ options: defaultOptions,
154
+ },
155
+
156
+ // 特殊パターン: HTMLタグを含む
157
+ {
158
+ code: "export const translations = { message: 'これは改行<br></br>のテストです' }",
159
+ filename: setupTestFile('valid-br-tag.ts', { message: 'これは改行<br></br>のテストです' }),
160
+ options: defaultOptions,
161
+ },
162
+
163
+ // 特殊パターン: 複数のプレースホルダーを含む
164
+ {
165
+ code: "export const translations = { count: '{count}件のうち{total}件を表示' }",
166
+ filename: setupTestFile('valid-multi-placeholder.ts', { count: '{count}件のうち{total}件を表示' }),
167
+ options: defaultOptions,
168
+ },
169
+
170
+ // 特殊パターン: 改行変数を含む
171
+ {
172
+ code: "export const translations = { error: 'エラーが発生しました。{br}再度お試しください。' }",
173
+ filename: setupTestFile('valid-br-variable.ts', { error: 'エラーが発生しました。{br}再度お試しください。' }),
174
+ options: defaultOptions,
175
+ },
176
+
177
+ // 特殊パターン: 複合的なパターン
178
+ {
179
+ code: "export const translations = { complex: '{user}さん、<strong>重要</strong>なお知らせです。{br}詳細は{link}をご確認ください。' }",
180
+ filename: setupTestFile('valid-complex.ts', { complex: '{user}さん、<strong>重要</strong>なお知らせです。{br}詳細は{link}をご確認ください。' }),
181
+ options: defaultOptions,
182
+ },
183
+
184
+ // 特殊パターン: 空のオブジェクト
185
+ {
186
+ code: "export const translations = {}",
187
+ filename: setupTestFile('valid-empty.ts', {}),
188
+ options: defaultOptions,
189
+ },
190
+
191
+ // 複数のexport: 型とオブジェクトを両方export
192
+ {
193
+ code: "export type TranslationKeys = string; export const translations = { key1: 'value1' }",
194
+ filename: setupTestFile('valid-with-type.ts', { key1: 'value1' }),
195
+ options: defaultOptions,
196
+ },
197
+
198
+ // 複数のexport: 型定義が複数ある場合
199
+ {
200
+ code: "export type Foo = string; export type Bar = number; export const translations = { key1: 'value1' }",
201
+ filename: setupTestFile('valid-with-multiple-types.ts', { key1: 'value1' }),
202
+ options: defaultOptions,
203
+ },
204
+ ],
205
+
206
+ invalid: [
207
+ // 複数のオブジェクトをexport
208
+ {
209
+ code: "export const a = { key1: 'value1' }; export const b = { key2: 'value2' }",
210
+ filename: createTestPath('ja.ts'),
211
+ options: defaultOptions,
212
+ errors: [{ messageId: 'multipleExports' }],
213
+ },
214
+
215
+ // オブジェクトのexportがない
216
+ {
217
+ code: "const translations = { key1: 'value1' }",
218
+ filename: createTestPath('ja.ts'),
219
+ options: defaultOptions,
220
+ errors: [{ messageId: 'noExport' }],
221
+ },
222
+
223
+ // オブジェクト以外をexport(文字列)
224
+ {
225
+ code: "export const translations = 'not an object'",
226
+ filename: createTestPath('ja.ts'),
227
+ options: defaultOptions,
228
+ errors: [{ messageId: 'noExport' }],
229
+ },
230
+
231
+ // オブジェクト以外をexport(配列)
232
+ {
233
+ code: "export const translations = ['array']",
234
+ filename: createTestPath('ja.ts'),
235
+ options: defaultOptions,
236
+ errors: [{ messageId: 'noExport' }],
237
+ },
238
+
239
+ // 値が文字列以外(数値)
240
+ {
241
+ code: "export const translations = { key1: 123 }",
242
+ filename: createTestPath('ja.ts'),
243
+ options: defaultOptions,
244
+ errors: [{ messageId: 'invalidValue' }],
245
+ },
246
+
247
+ // 値が文字列以外(真偽値)
248
+ {
249
+ code: "export const translations = { key1: true }",
250
+ filename: createTestPath('ja.ts'),
251
+ options: defaultOptions,
252
+ errors: [{ messageId: 'invalidValue' }],
253
+ },
254
+
255
+ // 値が文字列以外(null)
256
+ {
257
+ code: "export const translations = { key1: null }",
258
+ filename: createTestPath('ja.ts'),
259
+ options: defaultOptions,
260
+ errors: [{ messageId: 'invalidValue' }],
261
+ },
262
+
263
+ // ネストされたオブジェクト
264
+ {
265
+ code: "export const translations = { key1: { nested: 'value' } }",
266
+ filename: createTestPath('ja.ts'),
267
+ options: defaultOptions,
268
+ errors: [{ messageId: 'invalidValue' }],
269
+ },
270
+
271
+ // スプレッド構文
272
+ {
273
+ code: "const base = { key1: 'value1' }; export const translations = { ...base, key2: 'value2' }",
274
+ filename: createTestPath('ja.ts'),
275
+ options: defaultOptions,
276
+ errors: [{ messageId: 'invalidValue' }],
277
+ },
278
+
279
+ // テンプレートリテラルに式を含む
280
+ {
281
+ code: 'export const translations = { key1: `value${1}` }',
282
+ filename: createTestPath('ja.ts'),
283
+ options: defaultOptions,
284
+ errors: [{ messageId: 'invalidValue' }],
285
+ },
286
+
287
+ // JSONファイルが存在しない
288
+ {
289
+ code: "export const translations = { key1: 'value1' }",
290
+ filename: createTestPath('ja.ts'),
291
+ options: defaultOptions,
292
+ errors: [{ messageId: 'notSync' }],
293
+ },
294
+
295
+ // ja.ts と ja.json が不一致
296
+ {
297
+ code: "export const translations = { key1: 'new value' }",
298
+ filename: (() => {
299
+ const tsPath = path.join(tmpDir, 'sub1', 'ja.ts')
300
+ const jsonPath = path.join(tmpDir, 'sub1', 'ja.json')
301
+ fs.mkdirSync(path.dirname(tsPath), { recursive: true })
302
+ fs.writeFileSync(jsonPath, '{\n "key1": "old value"\n}\n', 'utf8')
303
+ return tsPath
304
+ })(),
305
+ options: defaultOptions,
306
+ errors: [{ messageId: 'notSync' }],
307
+ },
308
+ ],
309
+ })