eslint-plugin-smarthr 2.3.0 → 2.4.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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
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
+ ## [2.4.0](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v2.3.0...eslint-plugin-smarthr-v2.4.0) (2025-12-02)
6
+
7
+
8
+ ### Features
9
+
10
+ * Modalというコンポーネントの作成を禁止し、Dialogという名称に統一させる ([#915](https://github.com/kufu/tamatebako/issues/915)) ([208c2a0](https://github.com/kufu/tamatebako/commit/208c2a06034bcf24bfe1a3dd0d8633b0c546db11))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * a11y-prohibit-checkbox-or-radio-in-table-cellのautofixを削除 & childrenを持つ場合に警告されないよう修正 ([#914](https://github.com/kufu/tamatebako/issues/914)) ([3962fda](https://github.com/kufu/tamatebako/commit/3962fda01a1966c9624d5cd7056d807f86ccb5bc))
16
+ * trim-props でTemplateLiteralがネストしている場合のチェックを修正 ([#912](https://github.com/kufu/tamatebako/issues/912)) ([2d090c4](https://github.com/kufu/tamatebako/commit/2d090c4ef6d1ac4c9537fdb13a0198552456c10c))
17
+
5
18
  ## [2.3.0](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v2.2.1...eslint-plugin-smarthr-v2.3.0) (2025-12-01)
6
19
 
7
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-smarthr",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "author": "SmartHR",
5
5
  "license": "MIT",
6
6
  "description": "A sharable ESLint plugin for SmartHR",
@@ -37,5 +37,5 @@
37
37
  "eslintplugin",
38
38
  "smarthr"
39
39
  ],
40
- "gitHead": "18f89a924a8920722a1e71982a4d117db0c6a073"
40
+ "gitHead": "6b27e405cda722121437718a67b5886cb13ed534"
41
41
  }
@@ -1,12 +1,3 @@
1
- const findClosestThFromAncestor = (node) => {
2
- if (node.type === 'JSXElement' && node.openingElement.name.name === 'Th') {
3
- return node
4
- }
5
- if (node.parent) {
6
- return findClosestThFromAncestor(node.parent)
7
- }
8
- }
9
-
10
1
  /**
11
2
  * @type {import('@typescript-eslint/utils').TSESLint.RuleModule<''>}
12
3
  */
@@ -20,10 +11,8 @@ module.exports = {
20
11
  },
21
12
  },
22
13
  create(context) {
23
- const sourceCode = context.sourceCode
24
-
25
14
  return {
26
- 'JSXElement[openingElement.name.name=/Td$/] JSXElement[openingElement.name.name=/Check(b|B)ox$/]': (node) => {
15
+ 'JSXElement[openingElement.name.name=/Td$/] JSXElement[openingElement.name.name=/Check(b|B)ox$/][children.length=0]': (node) => {
27
16
  context.report({
28
17
  node,
29
18
  messageId: 'default',
@@ -34,7 +23,7 @@ module.exports = {
34
23
  },
35
24
  })
36
25
  },
37
- 'JSXElement[openingElement.name.name=/Td$/] JSXElement[openingElement.name.name=/RadioButton$/]': (node) => {
26
+ 'JSXElement[openingElement.name.name=/Td$/] JSXElement[openingElement.name.name=/RadioButton$/][children.length=0]': (node) => {
38
27
  context.report({
39
28
  node,
40
29
  messageId: 'default',
@@ -45,7 +34,7 @@ module.exports = {
45
34
  },
46
35
  })
47
36
  },
48
- 'JSXElement[openingElement.name.name=/Th$/] JSXElement[openingElement.name.name=/Check(b|B)ox$/]': (node) => {
37
+ 'JSXElement[openingElement.name.name=/Th$/] JSXElement[openingElement.name.name=/Check(b|B)ox$/][children.length=0]': (node) => {
49
38
  context.report({
50
39
  node,
51
40
  messageId: 'default',
@@ -54,14 +43,6 @@ module.exports = {
54
43
  component: 'Checkbox',
55
44
  preferred: 'ThCheckbox',
56
45
  },
57
- *fix(fixer) {
58
- const th = findClosestThFromAncestor(node)
59
- if (th) {
60
- const thCheckbox = sourceCode.getText(node).replace(/<Check(b|B)ox/, '<ThCheckbox')
61
- yield fixer.insertTextAfter(th, thCheckbox)
62
- yield fixer.remove(th)
63
- }
64
- },
65
46
  })
66
47
  },
67
48
  }
@@ -1,4 +1,60 @@
1
- const { generateTagFormatter } = require('../../libs/format_styled_components')
1
+ const STYLED_COMPONENTS_METHOD = 'styled'
2
+ const STYLED_COMPONENTS = `${STYLED_COMPONENTS_METHOD}-components`
3
+
4
+ const findInvalidImportNameNode = (s) => s.type === 'ImportDefaultSpecifier' && s.local.name !== STYLED_COMPONENTS_METHOD
5
+
6
+ const checkImportStyledComponents = (node, context) => {
7
+ if (node.source.value !== STYLED_COMPONENTS) {
8
+ return
9
+ }
10
+
11
+ const invalidNameNode = node.specifiers.find(findInvalidImportNameNode)
12
+
13
+ if (invalidNameNode) {
14
+ context.report({
15
+ node: invalidNameNode,
16
+ message: `${STYLED_COMPONENTS} をimportする際は、名称が"${STYLED_COMPONENTS_METHOD}" となるようにしてください。例: "import ${STYLED_COMPONENTS_METHOD} from '${STYLED_COMPONENTS}'"`,
17
+ });
18
+ }
19
+ }
20
+
21
+ const getStyledComponentBaseName = (node) => {
22
+ let base = null
23
+
24
+ if (!node.init) {
25
+ return base
26
+ }
27
+
28
+ const tag = node.init.tag || node.init
29
+
30
+ if (tag.object?.name === STYLED_COMPONENTS_METHOD) {
31
+ base = tag.property.name
32
+ } else if (tag.callee) {
33
+ const callee = tag.callee
34
+
35
+ switch (STYLED_COMPONENTS_METHOD) {
36
+ case callee.name: {
37
+ const arg = tag.arguments[0]
38
+ base = arg.name || arg.value
39
+ break
40
+ }
41
+ case callee.callee?.name: {
42
+ const arg = callee.arguments[0]
43
+ base = arg.name || arg.value
44
+ break
45
+ }
46
+ case callee.object?.name:
47
+ base = callee.property.name
48
+ break
49
+ case callee.object?.callee?.name:
50
+ const arg = callee.object.arguments[0]
51
+ base = arg.name || arg.value
52
+ break
53
+ }
54
+ }
55
+
56
+ return base
57
+ }
2
58
 
3
59
  const EXPECTED_NAMES = {
4
60
  '(A|^a)rticle$': 'Article$',
@@ -134,7 +190,79 @@ module.exports = {
134
190
  schema: SCHEMA,
135
191
  },
136
192
  create(context) {
137
- return generateTagFormatter({ context, EXPECTED_NAMES, UNEXPECTED_NAMES })
193
+ const entriesesTagNames = Object.entries(EXPECTED_NAMES).map(([b, e]) => [ new RegExp(b), new RegExp(e) ])
194
+ const entriesesUnTagNames = UNEXPECTED_NAMES ? Object.entries(UNEXPECTED_NAMES).map(([b, e]) => {
195
+ const [ auctualE, messageTemplate ] = Array.isArray(e) ? e : [e, '']
196
+
197
+ return [ new RegExp(b), new RegExp(auctualE), messageTemplate ]
198
+ }) : []
199
+
200
+
201
+ const checkImportedNameToLocalName = (node, base, extended, isImport) => {
202
+ entriesesTagNames.forEach(([b, e]) => {
203
+ if (base.match(b) && !extended.match(e)) {
204
+ context.report({
205
+ node,
206
+ message: `${extended}を正規表現 "${e.toString()}" がmatchする名称に変更してください。${isImport ? `
207
+ - ${base}が型の場合、'import type { ${base} as ${extended} }' もしくは 'import { type ${base} as ${extended} }' のように明示的に型であることを宣言してください。名称変更が不要になります` : ''}`,
208
+ });
209
+ }
210
+ })
211
+ }
212
+
213
+ return {
214
+ ImportDeclaration: (node) => {
215
+ checkImportStyledComponents(node, context)
216
+
217
+ if (node.importKind !== 'type') {
218
+ node.specifiers.forEach((s) => {
219
+ if (s.importKind !== 'type' && s.imported && s.imported.name !== s.local.name) {
220
+ checkImportedNameToLocalName(node, s.imported.name, s.local.name, true)
221
+ }
222
+ })
223
+ }
224
+ },
225
+ VariableDeclarator: (node) => {
226
+ const base = getStyledComponentBaseName(node)
227
+
228
+ if (base) {
229
+ const extended = node.id.name
230
+
231
+ checkImportedNameToLocalName(node, base, extended)
232
+
233
+ entriesesUnTagNames.forEach(([b, e, m]) => {
234
+ const matcher = extended.match(e)
235
+
236
+ if (matcher && !base.match(b)) {
237
+ const expected = matcher[1]
238
+ const isBareTag = base === base.toLowerCase()
239
+ const sampleFixBase = `styled${isBareTag ? `.${base}` : `(${base})`}`
240
+
241
+ context.report({
242
+ node,
243
+ message: m ? m
244
+ .replaceAll('{{extended}}', extended)
245
+ .replaceAll('{{expected}}', expected) : `${extended} は ${b.toString()} にmatchする名前のコンポーネントを拡張することを期待している名称になっています
246
+ - ${extended} の名称の末尾が"${expected}" という文字列ではない状態にしつつ、"${base}"を継承していることをわかる名称に変更してください
247
+ - もしくは"${base}"を"${extended}"の継承元であることがわかるような${isBareTag ? '適切なタグや別コンポーネントに差し替えてください' : '名称に変更するか、適切な別コンポーネントに差し替えてください'}
248
+ - 修正例1: const ${extended.replace(expected, '')}Xxxx = ${sampleFixBase}
249
+ - 修正例2: const ${extended}Xxxx = ${sampleFixBase}
250
+ - 修正例3: const ${extended} = styled(Xxxx${expected})`
251
+ })
252
+ }
253
+ })
254
+ }
255
+ },
256
+ 'VariableDeclarator[id.name=/Modal/]': (node) => {
257
+ context.report({
258
+ node,
259
+ message: `コンポーネント名や変数名に"Modal"という名称は使わず、"Dialog"に統一してください
260
+ - Modalとは形容詞であり、かつ"現在の操作から切り離して専用の操作を行わせる" という意味合いを持ちます
261
+ - そのためDialogでなければ正しくない場合がありえます(smarthr-ui/ModelessDialogのように元々の操作も行えるDialogなどが該当)
262
+ - DialogはModalなダイアログ、Modelessなダイアログすべてを含有した名称のため、統一することを推奨しています`
263
+ })
264
+ },
265
+ }
138
266
  },
139
267
  }
140
268
  module.exports.schema = SCHEMA
@@ -1,5 +1,17 @@
1
1
  const SCHEMA = []
2
2
 
3
+ const searchBubbleUp = (node) => {
4
+ switch (node.type) {
5
+ case 'Program':
6
+ case 'JSXAttribute':
7
+ return null
8
+ case 'TemplateLiteral':
9
+ return node
10
+ }
11
+
12
+ return searchBubbleUp(node.parent)
13
+ }
14
+
3
15
  /**
4
16
  * @type {import('@typescript-eslint/utils').TSESLint.RuleModule<''>}
5
17
  */
@@ -10,21 +22,20 @@ module.exports = {
10
22
  fixable: 'whitespace',
11
23
  },
12
24
  create(context) {
13
- return {
14
- 'JSXAttribute Literal[value=/(^ | $)/]': (node) => {
25
+ const checker = (node) => {
26
+ // HINT: TemplateLiteralがネストしている場合、親側だけチェックする
27
+ if (!searchBubbleUp(node.parent)) {
15
28
  return context.report({
16
29
  node,
17
30
  message: '属性に設定している文字列から先頭、末尾の空白文字を削除してください',
18
- fix: (fixer) => fixer.replaceText(node, context.sourceCode.getText(node).replace(/^('|")\s+/, '$1').replace(/\s+('|")$/, '$1')),
31
+ fix: (fixer) => fixer.replaceText(node, context.sourceCode.getText(node).replace(/^('|"|`)\s+/, '$1').replace(/\s+('|"|`)$/, '$1')),
19
32
  })
20
- },
21
- 'JSXAttribute TemplateLiteral:has(TemplateElement:matches(:first-child[value.raw=/^ /],:last-child[value.raw=/ $/]))': (node) => {
22
- return context.report({
23
- node,
24
- message: '属性に設定している文字列から先頭、末尾の空白文字を削除してください',
25
- fix: (fixer) => fixer.replaceText(node, context.sourceCode.getText(node).replace(/(^`\s+|\s+`$)/g, '`')),
26
- })
27
- },
33
+ }
34
+ }
35
+
36
+ return {
37
+ 'JSXAttribute Literal[value=/(^ | $)/]': checker,
38
+ 'JSXAttribute TemplateLiteral:has(>TemplateElement:matches(:first-child[value.raw=/^ /],:last-child[value.raw=/ $/]))': checker,
28
39
  }
29
40
  },
30
41
  }
@@ -13,7 +13,20 @@ const ruleTester = new RuleTester({
13
13
  })
14
14
 
15
15
  ruleTester.run('a11y-prohibit-checkbox-or-radio-in-table-cell', rule, {
16
- valid: ['<TdCheckbox />', '<ThCheckbox />', '<TdRadioButton />', '<Td>hello</Td>', '<Th>hello</Th>'],
16
+ valid: [
17
+ '<TdCheckbox />',
18
+ '<ThCheckbox />',
19
+ '<TdRadioButton />',
20
+ '<Td>hello</Td>',
21
+ '<Th>hello</Th>',
22
+ `
23
+ <Td>
24
+ <Checkbox>
25
+ 可視ラベル
26
+ </Checkbox>
27
+ </Td>
28
+ `,
29
+ ],
17
30
  invalid: [
18
31
  {
19
32
  code: `<Td><Checkbox /></Td>`,
@@ -21,12 +34,10 @@ ruleTester.run('a11y-prohibit-checkbox-or-radio-in-table-cell', rule, {
21
34
  },
22
35
  {
23
36
  code: `<Th><Checkbox /></Th>`,
24
- output: `<ThCheckbox />`,
25
37
  errors: [{ message: 'Th の子孫に Checkbox を置くことはできません。代わりに ThCheckbox を使用してください。' }],
26
38
  },
27
39
  {
28
40
  code: `<Th><Checkbox id="my-checkbox" name="agree" error /></Th>`,
29
- output: `<ThCheckbox id="my-checkbox" name="agree" error />`,
30
41
  errors: [{ message: 'Th の子孫に Checkbox を置くことはできません。代わりに ThCheckbox を使用してください。' }],
31
42
  },
32
43
  {
@@ -49,7 +60,6 @@ ruleTester.run('a11y-prohibit-checkbox-or-radio-in-table-cell', rule, {
49
60
  },
50
61
  {
51
62
  code: `<CustomTh><CustomCheckbox /></CustomTh>`,
52
- output: null,
53
63
  errors: [{ message: 'Th の子孫に Checkbox を置くことはできません。代わりに ThCheckbox を使用してください。' }],
54
64
  },
55
65
  {
@@ -243,5 +243,9 @@ ruleTester.run('component-name', rule, {
243
243
 
244
244
  { code: 'const Hoge = styled(RemoteDialogTrigger)``', errors: [ { message: messageInheritance({ extended: 'Hoge', matcher: /DialogTrigger$/ }) }, { message: messageInheritance({ extended: 'Hoge', matcher: /RemoteDialogTrigger$/ }) } ] },
245
245
  { code: 'const Fuga = styled(RemoteTriggerActionDialog)``', errors: [ { message: messageInheritance({ extended: 'Fuga', matcher: /RemoteTrigger(.+)Dialog$/ }) } ] },
246
+ { code: 'const HogeModalFuga = any', errors: [ { message: `コンポーネント名や変数名に"Modal"という名称は使わず、"Dialog"に統一してください
247
+ - Modalとは形容詞であり、かつ"現在の操作から切り離して専用の操作を行わせる" という意味合いを持ちます
248
+ - そのためDialogでなければ正しくない場合がありえます(smarthr-ui/ModelessDialogのように元々の操作も行えるDialogなどが該当)
249
+ - DialogはModalなダイアログ、Modelessなダイアログすべてを含有した名称のため、統一することを推奨しています` } ] },
246
250
  ]
247
251
  })
@@ -19,6 +19,7 @@ ruleTester.run('trim-props', rule, {
19
19
  { code: `<img src={'/sample.jpg'} alt={'sample'} />` },
20
20
  { code: '<div data-spec="info-area">....</div>' },
21
21
  { code: '<div data-spec={`a${b} c`}>....</div>' },
22
+ { code: '<div data-spec={`a${b ? ` ${c} ` : " "} d`}>....</div>' },
22
23
  ],
23
24
  invalid: [
24
25
  {
@@ -87,5 +88,10 @@ ruleTester.run('trim-props', rule, {
87
88
  output: '<div data-spec={`a${b} c`}>....</div>',
88
89
  errors: [{ message: '属性に設定している文字列から先頭、末尾の空白文字を削除してください' }],
89
90
  },
91
+ {
92
+ code: '<div data-spec={` a${b ? ` ${c} ` : " "} d `}>....</div>',
93
+ output: '<div data-spec={`a${b ? ` ${c} ` : " "} d`}>....</div>',
94
+ errors: [{ message: '属性に設定している文字列から先頭、末尾の空白文字を削除してください' }],
95
+ },
90
96
  ],
91
97
  })
@@ -1,126 +0,0 @@
1
- const STYLED_COMPONENTS_METHOD = 'styled'
2
- const STYLED_COMPONENTS = `${STYLED_COMPONENTS_METHOD}-components`
3
-
4
- const findInvalidImportNameNode = (s) => s.type === 'ImportDefaultSpecifier' && s.local.name !== STYLED_COMPONENTS_METHOD
5
-
6
- const checkImportStyledComponents = (node, context) => {
7
- if (node.source.value !== STYLED_COMPONENTS) {
8
- return
9
- }
10
-
11
- const invalidNameNode = node.specifiers.find(findInvalidImportNameNode)
12
-
13
- if (invalidNameNode) {
14
- context.report({
15
- node: invalidNameNode,
16
- message: `${STYLED_COMPONENTS} をimportする際は、名称が"${STYLED_COMPONENTS_METHOD}" となるようにしてください。例: "import ${STYLED_COMPONENTS_METHOD} from '${STYLED_COMPONENTS}'"`,
17
- });
18
- }
19
- }
20
-
21
- const getStyledComponentBaseName = (node) => {
22
- let base = null
23
-
24
- if (!node.init) {
25
- return base
26
- }
27
-
28
- const tag = node.init.tag || node.init
29
-
30
- if (tag.object?.name === STYLED_COMPONENTS_METHOD) {
31
- base = tag.property.name
32
- } else if (tag.callee) {
33
- const callee = tag.callee
34
-
35
- switch (STYLED_COMPONENTS_METHOD) {
36
- case callee.name: {
37
- const arg = tag.arguments[0]
38
- base = arg.name || arg.value
39
- break
40
- }
41
- case callee.callee?.name: {
42
- const arg = callee.arguments[0]
43
- base = arg.name || arg.value
44
- break
45
- }
46
- case callee.object?.name:
47
- base = callee.property.name
48
- break
49
- case callee.object?.callee?.name:
50
- const arg = callee.object.arguments[0]
51
- base = arg.name || arg.value
52
- break
53
- }
54
- }
55
-
56
- return base
57
- }
58
-
59
- const generateTagFormatter = ({ context, EXPECTED_NAMES, UNEXPECTED_NAMES }) => {
60
- const entriesesTagNames = Object.entries(EXPECTED_NAMES).map(([b, e]) => [ new RegExp(b), new RegExp(e) ])
61
- const entriesesUnTagNames = UNEXPECTED_NAMES ? Object.entries(UNEXPECTED_NAMES).map(([b, e]) => {
62
- const [ auctualE, messageTemplate ] = Array.isArray(e) ? e : [e, '']
63
-
64
- return [ new RegExp(b), new RegExp(auctualE), messageTemplate ]
65
- }) : []
66
-
67
-
68
- const checkImportedNameToLocalName = (node, base, extended, isImport) => {
69
- entriesesTagNames.forEach(([b, e]) => {
70
- if (base.match(b) && !extended.match(e)) {
71
- context.report({
72
- node,
73
- message: `${extended}を正規表現 "${e.toString()}" がmatchする名称に変更してください。${isImport ? `
74
- - ${base}が型の場合、'import type { ${base} as ${extended} }' もしくは 'import { type ${base} as ${extended} }' のように明示的に型であることを宣言してください。名称変更が不要になります` : ''}`,
75
- });
76
- }
77
- })
78
- }
79
-
80
- return {
81
- ImportDeclaration: (node) => {
82
- checkImportStyledComponents(node, context)
83
-
84
- if (node.importKind !== 'type') {
85
- node.specifiers.forEach((s) => {
86
- if (s.importKind !== 'type' && s.imported && s.imported.name !== s.local.name) {
87
- checkImportedNameToLocalName(node, s.imported.name, s.local.name, true)
88
- }
89
- })
90
- }
91
- },
92
- VariableDeclarator: (node) => {
93
- const base = getStyledComponentBaseName(node)
94
-
95
- if (base) {
96
- const extended = node.id.name
97
-
98
- checkImportedNameToLocalName(node, base, extended)
99
-
100
- entriesesUnTagNames.forEach(([b, e, m]) => {
101
- const matcher = extended.match(e)
102
-
103
- if (matcher && !base.match(b)) {
104
- const expected = matcher[1]
105
- const isBareTag = base === base.toLowerCase()
106
- const sampleFixBase = `styled${isBareTag ? `.${base}` : `(${base})`}`
107
-
108
- context.report({
109
- node,
110
- message: m ? m
111
- .replaceAll('{{extended}}', extended)
112
- .replaceAll('{{expected}}', expected) : `${extended} は ${b.toString()} にmatchする名前のコンポーネントを拡張することを期待している名称になっています
113
- - ${extended} の名称の末尾が"${expected}" という文字列ではない状態にしつつ、"${base}"を継承していることをわかる名称に変更してください
114
- - もしくは"${base}"を"${extended}"の継承元であることがわかるような${isBareTag ? '適切なタグや別コンポーネントに差し替えてください' : '名称に変更するか、適切な別コンポーネントに差し替えてください'}
115
- - 修正例1: const ${extended.replace(expected, '')}Xxxx = ${sampleFixBase}
116
- - 修正例2: const ${extended}Xxxx = ${sampleFixBase}
117
- - 修正例3: const ${extended} = styled(Xxxx${expected})`
118
- })
119
- }
120
- })
121
- }
122
- },
123
- }
124
- }
125
-
126
- module.exports = { generateTagFormatter }