eslint-plugin-smarthr 6.9.1 → 6.10.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,25 @@
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.10.1](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v6.10.0...eslint-plugin-smarthr-v6.10.1) (2026-04-08)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **require-barrel-import:** next.jsの特殊文字パスでのバグ修正とリファクタリング ([#1210](https://github.com/kufu/tamatebako/issues/1210)) ([458cfe9](https://github.com/kufu/tamatebako/commit/458cfe90084cbcee936bcb375216f5807880edd7))
11
+
12
+ ## [6.10.0](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v6.9.1...eslint-plugin-smarthr-v6.10.0) (2026-04-08)
13
+
14
+
15
+ ### Features
16
+
17
+ * **a11y-heading-in-sectioning-content:** Scrollerコンポーネントを許可リストに追加 ([#1204](https://github.com/kufu/tamatebako/issues/1204)) ([7131d5c](https://github.com/kufu/tamatebako/commit/7131d5c2b6feb55ac44f952250f58db962153399))
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * **best-practice-for-text-component:** key属性対応とコードの最適化 ([#1205](https://github.com/kufu/tamatebako/issues/1205)) ([1c9821c](https://github.com/kufu/tamatebako/commit/1c9821c1d2c4f5d8b09f2a1a1f7e18310aecc31e))
23
+
5
24
  ## [6.9.1](https://github.com/kufu/tamatebako/compare/eslint-plugin-smarthr-v6.9.0...eslint-plugin-smarthr-v6.9.1) (2026-04-07)
6
25
 
7
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-smarthr",
3
- "version": "6.9.1",
3
+ "version": "6.10.1",
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": "5f958652e81f7a69a347489108ff88cb1b3e56dd"
40
+ "gitHead": "e2e55eea3e72809fab55082ad6ef4da0c6025ae8"
41
41
  }
@@ -14,7 +14,7 @@ HeadingコンポーネントをSectioningContent(Article, Aside, Nav, Section)
14
14
 
15
15
  ## SectioningContentとして扱うコンポーネントについて
16
16
 
17
- このルールではsmarthr-ui/Layout系コンポーネント(Center, Reel, Sidebar, Stack)にas属性・forwardedAs属性で`section` `article` `aside` `nav` のいずれかの要素が指定されている場合、SectioningContentとして扱います。<br />
17
+ このルールではsmarthr-ui/Layout系コンポーネント(Center, Cluster, Reel, Scroller, Sidebar, Stack)にas属性・forwardedAs属性で`section` `article` `aside` `nav` のいずれかの要素が指定されている場合、SectioningContentとして扱います。<br />
18
18
  Layout系コンポーネントがSectioningContentとして扱われている場合、smarthr-uiの内部実装レベルでもSectioningContentとして扱われるため、前述のHeadingのレベルの自動計算が有効になります。
19
19
 
20
20
  ## section要素などbuildinのSectiongContentに属する要素の利用について
@@ -4,7 +4,7 @@ const declaratorHeadingRegex = /Heading$/
4
4
  const sectioningRegex = /((A(rticle|side))|Nav|Section|^SectioningFragment)$/
5
5
  const bareTagRegex = /^(article|aside|nav|section)$/
6
6
  const modelessDialogRegex = /ModelessDialog$/
7
- const layoutComponentRegex = /((C(ent|lust)er)|Reel|Sidebar|Stack|Base(Column)?)$/
7
+ const layoutComponentRegex = /((C(ent|lust)er)|Reel|Sidebar|Stack|Scroller|Base(Column)?)$/
8
8
  const asRegex = /^(as|forwardedAs)$/
9
9
  const ignoreCheckParentTypeRegex = /^(Program|ExportNamedDeclaration)$/
10
10
  const noHeadingTagNamesRegex = /^(span|legend)$/
@@ -1,6 +1,8 @@
1
1
  const SCHEMA = []
2
2
 
3
+ // ============================================================
3
4
  // smarthr-ui/Textコンポーネントのクラス名とプロパティのマッピング
5
+ // ============================================================
4
6
  const CLASS_TO_PROP_MAP = {
5
7
  // size
6
8
  'shr-text-2xs': { prop: 'size', value: 'XXS' },
@@ -32,35 +34,49 @@ const CLASS_TO_PROP_MAP = {
32
34
 
33
35
  const REGEX_CLASSNAME_SPLIT = /\s+/
34
36
 
35
- // ESLintセレクタの基本要素
36
- const ATTR_AS = 'JSXAttribute[name.name="as"]'
37
+ // ============================================================
38
+ // ESLintセレクタ構築用の定数
39
+ // ============================================================
40
+ const CONVERTIBLE_SHR_CLASSES = Object.keys(CLASS_TO_PROP_MAP).join('|')
41
+ const CONVERTIBLE_SHR_PATTERN = `(^|\\s)(${CONVERTIBLE_SHR_CLASSES})(\\s|$)`
42
+
37
43
  const ATTR_CLASSNAME = 'JSXAttribute[name.name="className"]'
38
- const ATTR_TEXT_PROPS = 'JSXAttribute[name.name=/^(size|weight|color|leading|italic|whiteSpace|maxLines|styleType|icon|prefixIcon|suffixIcon|iconGap)$/]'
39
44
  const LITERAL_TYPE = '[value.type="Literal"]'
40
- const HAS_SHR_CLASS = '[value.value=/shr-/]'
41
45
 
42
- // ESLintセレクタの共通部分
43
46
  const TEXT_OPENING = 'JSXOpeningElement[name.name="Text"]'
44
- const NOT_HAS_AS = `:not(:has(${ATTR_AS}))`
45
- const HAS_TEXT_PROPS = `:has(${ATTR_TEXT_PROPS})`
46
- const NOT_HAS_TEXT_PROPS = `:not(${HAS_TEXT_PROPS})`
47
+ const HAS_ANY_ATTR = ':has(JSXAttribute)'
48
+ const HAS_TEXT_PROPS = ':has(JSXAttribute[name.name=/^(size|weight|color|leading|italic|whiteSpace|maxLines|styleType|icon|prefixIcon|suffixIcon|iconGap)$/])'
47
49
  const CHILD_CLASSNAME_LITERAL = `> ${ATTR_CLASSNAME}${LITERAL_TYPE}`
48
- const NOT_HAS_SHR_CLASS = `:not(${HAS_SHR_CLASS})`
49
- const CHILD_AS_LITERAL = `> ${ATTR_AS}${LITERAL_TYPE}`
50
-
51
- // 完全なESLintセレクタ(事前計算)
52
- const SELECTOR_UNNECESSARY_TEXT_NO_ATTRS = `${TEXT_OPENING}:not(:has(JSXAttribute))`
53
- const SELECTOR_UNNECESSARY_TEXT_ONLY_AS = `${TEXT_OPENING}[attributes.length=1] ${CHILD_AS_LITERAL}`
54
- const SELECTOR_CONVERTIBLE_SHR_TO_PROPS = `${TEXT_OPENING}${NOT_HAS_AS}${NOT_HAS_TEXT_PROPS} ${CHILD_CLASSNAME_LITERAL}${HAS_SHR_CLASS}`
55
- const SELECTOR_UNNECESSARY_TEXT_CLASSNAME = `${TEXT_OPENING}${NOT_HAS_AS}${NOT_HAS_TEXT_PROPS} ${CHILD_CLASSNAME_LITERAL}${NOT_HAS_SHR_CLASS}`
56
- const SELECTOR_CONVERTIBLE_SHR_TO_PROPS_WITH_AS = `${TEXT_OPENING}:has(${ATTR_AS}${LITERAL_TYPE})${NOT_HAS_TEXT_PROPS} ${CHILD_CLASSNAME_LITERAL}${HAS_SHR_CLASS}`
57
- const SELECTOR_UNNECESSARY_TEXT_AS_CLASSNAME = `${TEXT_OPENING}:has(${ATTR_CLASSNAME}${LITERAL_TYPE}${NOT_HAS_SHR_CLASS})${NOT_HAS_TEXT_PROPS} ${CHILD_AS_LITERAL}`
58
- const SELECTOR_CONFLICTING_PROPS_SHR = `${TEXT_OPENING}${HAS_TEXT_PROPS} ${CHILD_CLASSNAME_LITERAL}`
50
+ const HAS_CONVERTIBLE_SHR_CLASS = `[value.value=/${CONVERTIBLE_SHR_PATTERN}/]`
51
+ const HAS_SPREAD = ':has(JSXSpreadAttribute)'
52
+ const NOT_HAS_SPREAD = `:not(${HAS_SPREAD})`
53
+ const NOT_HAS_AS_VARIABLE = ':not(:has(JSXAttribute[name.name="as"]:not([value.type="Literal"])))'
54
+
55
+ // セレクタ構築用の共通パターン
56
+ const TEXT_WITHOUT_TEXT_PROPS = `${TEXT_OPENING}:not(${HAS_TEXT_PROPS})`
57
+ const CLASSNAME_WITH_CONVERTIBLE_SHR = `${CHILD_CLASSNAME_LITERAL}${HAS_CONVERTIBLE_SHR_CLASS}`
58
+
59
+ // ============================================================
60
+ // ESLintセレクタ
61
+ // ============================================================
62
+ // Stage 1: shr-クラス → Text属性変換
63
+ const SELECTOR_CONVERTIBLE_SHR_TO_PROPS = `${TEXT_WITHOUT_TEXT_PROPS}${NOT_HAS_SPREAD} ${CLASSNAME_WITH_CONVERTIBLE_SHR}`
64
+ const SELECTOR_CONVERTIBLE_SHR_TO_PROPS_WITH_SPREAD = `${TEXT_WITHOUT_TEXT_PROPS}${HAS_SPREAD} ${CLASSNAME_WITH_CONVERTIBLE_SHR}`
65
+
66
+ // Stage 2: Text専用属性なし → HTML要素変換
67
+ const SELECTOR_UNNECESSARY_TEXT_NO_ATTRS = `${TEXT_OPENING}:not(${HAS_ANY_ATTR})${NOT_HAS_SPREAD}`
68
+ const SELECTOR_UNNECESSARY_TEXT_NO_CLASSNAME = `${TEXT_WITHOUT_TEXT_PROPS}:not(:has(${ATTR_CLASSNAME}))${HAS_ANY_ATTR}${NOT_HAS_SPREAD}${NOT_HAS_AS_VARIABLE}`
69
+ const SELECTOR_UNNECESSARY_TEXT_WITH_CLASSNAME = `${TEXT_WITHOUT_TEXT_PROPS}:has(${ATTR_CLASSNAME}${LITERAL_TYPE}:not(${HAS_CONVERTIBLE_SHR_CLASS}))${NOT_HAS_SPREAD}${NOT_HAS_AS_VARIABLE}`
70
+
71
+ // 矛盾検出
72
+ const SELECTOR_CONFLICTING_PROPS_SHR = `${TEXT_OPENING}${HAS_TEXT_PROPS}${NOT_HAS_SPREAD} ${CHILD_CLASSNAME_LITERAL}`
73
+
74
+ // ============================================================
75
+ // ヘルパー関数
76
+ // ============================================================
59
77
 
60
78
  /**
61
- * className属性の変換可能なクラス名のみを取得(パターン3専用: 矛盾チェック)
62
- * セレクタで [value.type="Literal"] を保証しているため、必ず文字列リテラル
63
- * trim-props ルールで先頭・末尾の空白は禁止されているため、trim() は不要
79
+ * className属性から変換可能なクラス名のみを抽出(矛盾検出用)
64
80
  */
65
81
  function getConvertible(classNameAttrNode) {
66
82
  const classNames = classNameAttrNode.value.value.split(REGEX_CLASSNAME_SPLIT)
@@ -76,9 +92,7 @@ function getConvertible(classNameAttrNode) {
76
92
  }
77
93
 
78
94
  /**
79
- * className属性のクラス名を解析し、1回のループで全ての情報を生成(パターン2-1, 2-2, 2-3専用)
80
- * セレクタで [value.type="Literal"] を保証しているため、必ず文字列リテラル
81
- * trim-props ルールで先頭・末尾の空白は禁止されているため、trim() は不要
95
+ * className属性を変換可能/不可能に分類し、Text属性ペアを生成
82
96
  */
83
97
  function categorizeClassNames(classNameAttrNode) {
84
98
  const classNames = classNameAttrNode.value.value.split(REGEX_CLASSNAME_SPLIT)
@@ -104,8 +118,7 @@ function categorizeClassNames(classNameAttrNode) {
104
118
  }
105
119
 
106
120
  /**
107
- * 指定した属性の値を取得(文字列リテラルのみ)
108
- * セレクタで [value.type="Literal"] を保証しているため、型チェックは不要
121
+ * 属性の文字列リテラル値を取得
109
122
  */
110
123
  function getAttributeLiteralValue(openingElement, attrName) {
111
124
  const attr = openingElement.attributes.find(
@@ -115,7 +128,7 @@ function getAttributeLiteralValue(openingElement, attrName) {
115
128
  }
116
129
 
117
130
  /**
118
- * 指定した属性名の属性ノードを取得
131
+ * 属性ノードを取得
119
132
  */
120
133
  function getAttributeNode(openingElement, attrName) {
121
134
  return openingElement.attributes.find(
@@ -134,72 +147,12 @@ module.exports = {
134
147
  },
135
148
  create(context) {
136
149
  return {
137
- // パターン1-1: 属性なし
138
- [SELECTOR_UNNECESSARY_TEXT_NO_ATTRS]: (node) => {
139
- context.report({
140
- node,
141
- message: `属性を持たないTextコンポーネントは、<span>に置き換えるか、要素を削除してテキストのみにすることを検討してください。
142
- - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-text-component
143
- - Textコンポーネントに属性がない場合、直接HTML要素を使用するか、不要な要素を削除することでシンプルになります
144
- - weight、size、color等の属性がある場合は、Textコンポーネントのまま利用してください`,
145
- })
146
- },
147
-
148
- // パターン1-2: as属性のみ(文字列リテラル)
149
- [SELECTOR_UNNECESSARY_TEXT_ONLY_AS]: (asAttrNode) => {
150
- const tagName = asAttrNode.value.value
151
-
152
- context.report({
153
- node: asAttrNode.parent,
154
- message: `as属性のみを持つTextコンポーネントは、ネイティブHTML要素(<${tagName}>)に置き換えてください。
155
- - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-text-component
156
- - Textコンポーネントにas以外の属性がない場合、直接HTML要素を使用することでシンプルになります
157
- - weight、size、color等の属性がある場合は、Textコンポーネントのまま利用してください`,
158
- fix(fixer) {
159
- const openingElement = asAttrNode.parent
160
- const jsxElement = openingElement.parent
161
- const sourceCode = context.sourceCode || context.getSourceCode()
162
-
163
- // 属性とその前のスペースを含めて削除
164
- const tokenBefore = sourceCode.getTokenBefore(asAttrNode)
165
- const rangeStart = tokenBefore.range[1]
166
- const rangeEnd = asAttrNode.range[1]
167
-
168
- return [
169
- fixer.removeRange([rangeStart, rangeEnd]),
170
- fixer.replaceText(openingElement.name, tagName),
171
- fixer.replaceText(jsxElement.closingElement.name, tagName)
172
- ]
173
- },
174
- })
175
- },
176
-
177
- // パターン2-1: classNameのみ(asなし)、Text属性なし、shr-クラスあり
150
+ // Stage 1: shr-クラス → Text属性変換(spread attributesなし)
178
151
  [SELECTOR_CONVERTIBLE_SHR_TO_PROPS]: (classNameAttrNode) => {
179
152
  const { nonConvertible, propSuggestions, convertible } = categorizeClassNames(classNameAttrNode)
180
153
  const openingElement = classNameAttrNode.parent
181
- const jsxElement = openingElement.parent
182
-
183
- // 変換可能なクラスが0個の場合、spanに変換(パターン1-3と同じ動作)
184
- if (!propSuggestions) {
185
- const classNameText = `className="${classNameAttrNode.value.value}"`
186
- context.report({
187
- node: openingElement,
188
- message: `Textコンポーネントの機能を使用していないため、ネイティブHTML要素(<span>)に置き換えてください。
189
- - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-text-component
190
- - 推奨: <span ${classNameText}>
191
- - Textコンポーネントの機能(weight、size、color等)を使用しない場合は、直接HTML要素を使用することでシンプルになります`,
192
- fix(fixer) {
193
- return [
194
- fixer.replaceText(openingElement.name, 'span'),
195
- fixer.replaceText(jsxElement.closingElement.name, 'span')
196
- ]
197
- },
198
- })
199
- return
200
- }
154
+ const sourceCode = context.sourceCode || context.getSourceCode()
201
155
 
202
- // 変換可能なクラスがある場合、属性に変換
203
156
  context.report({
204
157
  node: openingElement,
205
158
  message: `classNameで指定されたshr-プレフィックスのクラスは、Textコンポーネントの属性に置き換えてください。
@@ -208,157 +161,123 @@ module.exports = {
208
161
  - 変換可能なクラス: ${convertible}
209
162
  - shr-プレフィックスのクラスをTextの属性に置き換えることで、型安全性が向上し、意図がより明確になります`,
210
163
  fix(fixer) {
211
- const sourceCode = context.sourceCode || context.getSourceCode()
212
164
  const fixes = []
213
-
214
165
  if (nonConvertible) {
215
- // classNameの値を更新(属性自体は残す)
216
166
  fixes.push(fixer.replaceText(classNameAttrNode.value, `"${nonConvertible}"`))
217
167
  } else {
218
- // className属性全体を削除(shr-クラスのみの場合)
219
168
  const tokenBefore = sourceCode.getTokenBefore(classNameAttrNode)
220
- const rangeStart = tokenBefore.range[1]
221
- const rangeEnd = classNameAttrNode.range[1]
222
- fixes.push(fixer.removeRange([rangeStart, rangeEnd]))
169
+ fixes.push(fixer.removeRange([tokenBefore.range[1], classNameAttrNode.range[1]]))
170
+ }
171
+ const asAttrNode = getAttributeNode(openingElement, 'as')
172
+ if (asAttrNode) {
173
+ fixes.push(fixer.insertTextAfter(asAttrNode, ` ${propSuggestions}`))
174
+ } else {
175
+ fixes.push(fixer.insertTextAfter(openingElement.name, ` ${propSuggestions}`))
223
176
  }
224
-
225
- // 新しいpropsを追加
226
- fixes.push(fixer.insertTextAfter(openingElement.name, ` ${propSuggestions}`))
227
-
228
177
  return fixes
229
178
  },
230
179
  })
231
180
  },
232
181
 
233
- // パターン1-3: classNameのみ(asなし)、Text属性なし、shr-クラスなし
234
- [SELECTOR_UNNECESSARY_TEXT_CLASSNAME]: (classNameAttrNode) => {
235
- const classNameText = `className="${classNameAttrNode.value.value}"`
182
+ // Stage 1: shr-クラス → Text属性変換(spread attributesあり、fixなし)
183
+ [SELECTOR_CONVERTIBLE_SHR_TO_PROPS_WITH_SPREAD]: (classNameAttrNode) => {
184
+ const { convertible } = categorizeClassNames(classNameAttrNode)
185
+ const openingElement = classNameAttrNode.parent
236
186
 
237
187
  context.report({
238
- node: classNameAttrNode.parent,
239
- message: `Textコンポーネントの機能を使用していないため、ネイティブHTML要素(<span>)に置き換えてください。
188
+ node: openingElement,
189
+ message: `classNameで指定されたshr-プレフィックスのクラスは、Textコンポーネントの属性に置き換えてください。
240
190
  - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-text-component
241
- - 推奨: <span ${classNameText}>
242
- - Textコンポーネントの機能(weight、size、color等)を使用しない場合は、直接HTML要素を使用することでシンプルになります`,
243
- fix(fixer) {
244
- const openingElement = classNameAttrNode.parent
245
- const jsxElement = openingElement.parent
246
-
247
- return [
248
- fixer.replaceText(openingElement.name, 'span'),
249
- fixer.replaceText(jsxElement.closingElement.name, 'span')
250
- ]
251
- },
191
+ - 変換可能なクラス: ${convertible}
192
+ - spread attributes ({...props}) があるため自動修正できません。手動で修正してください
193
+ - shr-プレフィックスのクラスをTextの属性に置き換えることで、型安全性が向上し、意図がより明確になります`,
252
194
  })
253
195
  },
254
196
 
255
- // パターン2-2, 2-3: className + as(文字列リテラル)、Text属性なし、shr-クラスあり
256
- [SELECTOR_CONVERTIBLE_SHR_TO_PROPS_WITH_AS]: (classNameAttrNode) => {
257
- const { nonConvertible, propSuggestions, convertible } = categorizeClassNames(classNameAttrNode)
258
- const openingElement = classNameAttrNode.parent
259
- const jsxElement = openingElement.parent
260
- const asValue = getAttributeLiteralValue(openingElement, 'as')
261
-
262
- // 変換可能なクラスが0個の場合、as属性で指定されたタグに変換(パターン1-4と同じ動作)
263
- if (!propSuggestions) {
264
- const classNameValue = getAttributeLiteralValue(openingElement, 'className')
265
- const classNameText = `className="${classNameValue}"`
266
- context.report({
267
- node: openingElement,
268
- message: `Textコンポーネントの機能を使用していないため、ネイティブHTML要素に置き換えてください。
197
+ // Stage 2: 属性なし
198
+ [SELECTOR_UNNECESSARY_TEXT_NO_ATTRS]: (node) => {
199
+ context.report({
200
+ node,
201
+ message: `属性を持たないTextコンポーネントは、<span>に置き換えるか、要素を削除してテキストのみにすることを検討してください。
269
202
  - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-text-component
270
- - <${asValue}>要素にclassNameを移動してください
271
- - Textコンポーネントの機能(weight、size、color等)を使用しない場合は、直接HTML要素を使用することでシンプルになります`,
272
- fix(fixer) {
273
- const sourceCode = context.sourceCode || context.getSourceCode()
274
- const asAttrNode = getAttributeNode(openingElement, 'as')
203
+ - Textコンポーネントに属性がない場合、直接HTML要素を使用するか、不要な要素を削除することでシンプルになります
204
+ - weight、size、color等の属性がある場合は、Textコンポーネントのまま利用してください`,
205
+ })
206
+ },
275
207
 
276
- // 属性とその前のスペースを含めて削除
277
- const tokenBefore = sourceCode.getTokenBefore(asAttrNode)
278
- const rangeStart = tokenBefore.range[1]
279
- const rangeEnd = asAttrNode.range[1]
280
-
281
- return [
282
- fixer.removeRange([rangeStart, rangeEnd]),
283
- fixer.replaceText(openingElement.name, asValue),
284
- fixer.replaceText(jsxElement.closingElement.name, asValue)
285
- ]
286
- },
287
- })
288
- return
289
- }
208
+ // Stage 2: classNameなし
209
+ [SELECTOR_UNNECESSARY_TEXT_NO_CLASSNAME]: (node) => {
210
+ const asValue = getAttributeLiteralValue(node, 'as')
211
+ const tagName = asValue || 'span'
212
+ const sourceCode = context.sourceCode || context.getSourceCode()
290
213
 
291
- // 変換可能なクラスがある場合、属性に変換
292
214
  context.report({
293
- node: openingElement,
294
- message: `classNameで指定されたshr-プレフィックスのクラスは、Textコンポーネントの属性に置き換えてください。
215
+ node,
216
+ message: asValue
217
+ ? `as属性のみを持つTextコンポーネントは、ネイティブHTML要素(<${tagName}>)に置き換えてください。
295
218
  - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-text-component
296
- - 推奨: <Text${asValue ? ` as="${asValue}"` : ''} ${propSuggestions}${nonConvertible ? ` className="${nonConvertible}"` : ''}>
297
- - 変換可能なクラス: ${convertible}
298
- - shr-プレフィックスのクラスをTextの属性に置き換えることで、型安全性が向上し、意図がより明確になります`,
219
+ - Textコンポーネントにas以外の属性がない場合、直接HTML要素を使用することでシンプルになります
220
+ - weight、size、color等の属性がある場合は、Textコンポーネントのまま利用してください`
221
+ : `Textコンポーネントの機能を使用していないため、ネイティブHTML要素(<span>)に置き換えてください。
222
+ - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-text-component
223
+ - 推奨: <span>
224
+ - Textコンポーネントの機能(weight、size、color等)を使用しない場合は、直接HTML要素を使用することでシンプルになります`,
299
225
  fix(fixer) {
300
- const sourceCode = context.sourceCode || context.getSourceCode()
226
+ const jsxElement = node.parent
301
227
  const fixes = []
302
-
303
- if (nonConvertible) {
304
- // classNameの値を更新(属性自体は残す)
305
- fixes.push(fixer.replaceText(classNameAttrNode.value, `"${nonConvertible}"`))
306
- } else {
307
- // className属性全体を削除(shr-クラスのみの場合)
308
- const tokenBefore = sourceCode.getTokenBefore(classNameAttrNode)
309
- const rangeStart = tokenBefore.range[1]
310
- const rangeEnd = classNameAttrNode.range[1]
311
- fixes.push(fixer.removeRange([rangeStart, rangeEnd]))
312
- }
313
-
314
- // 新しいpropsを追加(as属性がある場合はその後ろに挿入)
315
- const asAttrNode = getAttributeNode(openingElement, 'as')
316
- if (asAttrNode) {
317
- fixes.push(fixer.insertTextAfter(asAttrNode, ` ${propSuggestions}`))
318
- } else {
319
- fixes.push(fixer.insertTextAfter(openingElement.name, ` ${propSuggestions}`))
228
+ // as属性があれば削除
229
+ if (asValue) {
230
+ const asAttrNode = getAttributeNode(node, 'as')
231
+ const tokenBefore = sourceCode.getTokenBefore(asAttrNode)
232
+ fixes.push(fixer.removeRange([tokenBefore.range[1], asAttrNode.range[1]]))
320
233
  }
321
-
234
+ fixes.push(fixer.replaceText(node.name, tagName))
235
+ fixes.push(fixer.replaceText(jsxElement.closingElement.name, tagName))
322
236
  return fixes
323
237
  },
324
238
  })
325
239
  },
326
240
 
327
- // パターン1-4: className + as(文字列リテラル)、Text属性なし、shr-クラスなし
328
- [SELECTOR_UNNECESSARY_TEXT_AS_CLASSNAME]: (asAttrNode) => {
329
- const tagName = asAttrNode.value.value
330
- const openingElement = asAttrNode.parent
331
- const classNameValue = getAttributeLiteralValue(openingElement, 'className')
332
- const classNameText = `className="${classNameValue}"`
241
+ // Stage 2: classNameあり
242
+ [SELECTOR_UNNECESSARY_TEXT_WITH_CLASSNAME]: (node) => {
243
+ const asValue = getAttributeLiteralValue(node, 'as')
244
+ const classNameValue = getAttributeLiteralValue(node, 'className')
245
+ const tagName = asValue || 'span'
246
+ const sourceCode = context.sourceCode || context.getSourceCode()
247
+
248
+ // classNameは常に表示(fixerが自動で保持するため)
249
+ const classNameText = classNameValue ? ` className="${classNameValue}"` : ''
333
250
 
334
251
  context.report({
335
- node: openingElement,
336
- message: `Textコンポーネントの機能を使用していないため、ネイティブHTML要素に置き換えてください。
252
+ node,
253
+ message: asValue
254
+ ? `Textコンポーネントの機能を使用していないため、ネイティブHTML要素に置き換えてください。
337
255
  - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-text-component
338
256
  - <${tagName}>要素にclassNameを移動してください
257
+ - Textコンポーネントの機能(weight、size、color等)を使用しない場合は、直接HTML要素を使用することでシンプルになります`
258
+ : `Textコンポーネントの機能を使用していないため、ネイティブHTML要素(<span>)に置き換えてください。
259
+ - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-text-component
260
+ - 推奨: <span${classNameText}>
339
261
  - Textコンポーネントの機能(weight、size、color等)を使用しない場合は、直接HTML要素を使用することでシンプルになります`,
340
262
  fix(fixer) {
341
- const jsxElement = openingElement.parent
342
- const sourceCode = context.sourceCode || context.getSourceCode()
343
-
344
- // 属性とその前のスペースを含めて削除
345
- const tokenBefore = sourceCode.getTokenBefore(asAttrNode)
346
- const rangeStart = tokenBefore.range[1]
347
- const rangeEnd = asAttrNode.range[1]
348
-
349
- return [
350
- fixer.removeRange([rangeStart, rangeEnd]),
351
- fixer.replaceText(openingElement.name, tagName),
352
- fixer.replaceText(jsxElement.closingElement.name, tagName)
353
- ]
263
+ const jsxElement = node.parent
264
+ const fixes = []
265
+ // as属性があれば削除
266
+ if (asValue) {
267
+ const asAttrNode = getAttributeNode(node, 'as')
268
+ const tokenBefore = sourceCode.getTokenBefore(asAttrNode)
269
+ fixes.push(fixer.removeRange([tokenBefore.range[1], asAttrNode.range[1]]))
270
+ }
271
+ fixes.push(fixer.replaceText(node.name, tagName))
272
+ fixes.push(fixer.replaceText(jsxElement.closingElement.name, tagName))
273
+ return fixes
354
274
  },
355
275
  })
356
276
  },
357
277
 
358
- // パターン3: Text属性あり、classNameにshr-クラスあり(矛盾)
278
+ // 矛盾検出
359
279
  [SELECTOR_CONFLICTING_PROPS_SHR]: (classNameAttrNode) => {
360
280
  const convertible = getConvertible(classNameAttrNode)
361
-
362
281
  if (convertible) {
363
282
  context.report({
364
283
  node: classNameAttrNode.parent,
@@ -31,62 +31,169 @@ const SCHEMA = [
31
31
  }
32
32
  ]
33
33
 
34
- const entriedReplacePaths = Object.entries(replacePaths)
35
34
  const CWD = process.cwd()
36
35
  const REGEX_UNNECESSARY_SLASH = /(\/)+/g
37
36
  const REGEX_ROOT_PATH = new RegExp(`^${rootPath}/index\.`)
38
37
  const REGEX_INDEX_FILE = /\/index\.(ts|js)x?$/
39
38
  const TARGET_EXTS = ['ts', 'tsx', 'js', 'jsx']
40
39
 
41
- // 正規表現を事前生成してキャッシュ
42
- const entriedReplacePathsWithRegex = entriedReplacePaths.map(([key, values]) => [
40
+ // Path aliasの正規表現を事前生成してキャッシュ
41
+ const entriedReplacePathsWithRegex = Object.entries(replacePaths).map(([key, values]) => [
43
42
  key,
44
43
  values,
45
44
  new RegExp(`^${key}(.+)$`),
46
45
  values.map(v => new RegExp(`^${path.resolve(`${CWD}/${v}`)}(.+)$`))
47
46
  ])
48
47
 
49
- const calculateAbsoluteImportPath = (source) => {
50
- if (source[0] === '/') {
51
- return source
48
+ /**
49
+ * Path aliasを絶対パスに変換する
50
+ * @param {string} importPath - import文のパス(例: '@/components/Button')
51
+ * @returns {string} 絶対パス(例: '/path/to/src/components/Button')
52
+ */
53
+ const resolvePathAlias = (importPath) => {
54
+ if (importPath[0] === '/') {
55
+ return importPath
52
56
  }
53
57
 
54
- return entriedReplacePathsWithRegex.reduce((prev, [key, values, keyRegex]) => {
55
- if (source === prev) {
56
- return values.reduce((p, v) => {
57
- if (prev === p && keyRegex.test(keyRegex)) {
58
- return p.replace(keyRegex, `${path.resolve(`${CWD}/${v}`)}/$1`)
58
+ return entriedReplacePathsWithRegex.reduce((result, [key, values, keyRegex]) => {
59
+ if (result === importPath) {
60
+ return values.reduce((resolved, value) => {
61
+ if (resolved === result && keyRegex.test(result)) {
62
+ return resolved.replace(keyRegex, `${path.resolve(`${CWD}/${value}`)}/$1`)
59
63
  }
60
-
61
- return p
62
- }, prev)
64
+ return resolved
65
+ }, result)
63
66
  }
64
-
65
- return prev
66
- }, source)
67
+ return result
68
+ }, importPath)
67
69
  }
68
- const calculateReplacedImportPath = (source) => {
69
- return entriedReplacePathsWithRegex.reduce((prev, [key, values, keyRegex, valueRegexes]) => {
70
- if (source === prev) {
71
- return values.reduce((p, v, index) => {
72
- if (prev === p) {
73
- const regexp = valueRegexes[index]
74
70
 
75
- if (regexp.test(prev)) {
76
- return p.replace(regexp, `${key}/$1`).replace(REGEX_UNNECESSARY_SLASH, '/')
71
+ /**
72
+ * 絶対パスをPath aliasに変換する
73
+ * @param {string} absolutePath - 絶対パス(例: '/path/to/src/components/Button')
74
+ * @returns {string} Path alias(例: '@/components/Button')
75
+ */
76
+ const convertToPathAlias = (absolutePath) => {
77
+ return entriedReplacePathsWithRegex.reduce((result, [key, values, keyRegex, valueRegexes]) => {
78
+ if (result === absolutePath) {
79
+ return values.reduce((converted, value, index) => {
80
+ if (converted === result) {
81
+ const regexp = valueRegexes[index]
82
+ if (regexp.test(converted)) {
83
+ return converted.replace(regexp, `${key}/$1`).replace(REGEX_UNNECESSARY_SLASH, '/')
77
84
  }
78
85
  }
86
+ return converted
87
+ }, result)
88
+ }
89
+ return result
90
+ }, absolutePath)
91
+ }
92
+
93
+ /**
94
+ * import先がimport元の内部にあるかチェック(同階層・サブディレクトリからのimport)
95
+ * (Next.js App Routerの特殊文字パスにも対応)
96
+ * @param {string} importerDir - import元のディレクトリ
97
+ * @param {string} importedPath - import先のパス
98
+ * @returns {boolean}
99
+ */
100
+ const isImportedInsideImporter = (importerDir, importedPath) => {
101
+ return importedPath === importerDir || importedPath.startsWith(importerDir + '/')
102
+ }
79
103
 
80
- return p
81
- }, prev)
104
+ /**
105
+ * allowedImportsオプションに基づいて、特定のimportが許可されているかチェックする
106
+ * @param {object} node - ImportDeclaration node
107
+ * @param {string} importerDir - import元のディレクトリ
108
+ * @param {Array} targetAllowedImports - 適用されるallowedImportsのキー配列
109
+ * @param {object} allowedImportsConfig - allowedImportsの設定
110
+ * @returns {{ shouldSkip: boolean, deniedModules: Array }} チェック結果
111
+ */
112
+ const checkAllowedImports = (node, importerDir, targetAllowedImports, allowedImportsConfig) => {
113
+ let isDenyPath = false
114
+ let deniedModules = []
115
+
116
+ for (const allowedKey of targetAllowedImports) {
117
+ const allowedOption = allowedImportsConfig[allowedKey]
118
+
119
+ for (const targetModule in allowedOption) {
120
+ const actualTarget = targetModule[0] !== '.'
121
+ ? targetModule
122
+ : path.resolve(`${CWD}/${targetModule}`)
123
+
124
+ let importSource = node.source.value
125
+
126
+ // 絶対パスの場合は、import元ディレクトリを基準に解決
127
+ if (actualTarget[0] === '/') {
128
+ importSource = path.resolve(`${importerDir}/${importSource}`)
129
+ }
130
+
131
+ if (actualTarget !== importSource) {
132
+ continue
133
+ }
134
+
135
+ const allowedModules = allowedOption[targetModule] || true
136
+
137
+ if (!Array.isArray(allowedModules)) {
138
+ isDenyPath = true
139
+ deniedModules.push(true)
140
+ } else {
141
+ const importedNames = node.specifiers.map(s => s.imported?.name)
142
+ const notAllowedModules = importedNames.filter(name => !allowedModules.includes(name))
143
+ deniedModules.push(notAllowedModules)
144
+ }
82
145
  }
146
+ }
147
+
148
+ // 完全に許可されている場合はスキップ
149
+ const shouldSkip =
150
+ (isDenyPath && deniedModules[0] === true) ||
151
+ (!isDenyPath && deniedModules.length === 1 && deniedModules[0].length === 0)
83
152
 
84
- return prev
85
- }, source)
153
+ return { shouldSkip, deniedModules }
86
154
  }
87
155
 
88
- const pickImportedName = (s) => s.imported?.name
89
- const findExistsSync = (p) => fs.existsSync(p)
156
+ /**
157
+ * import先のパスから親方向に barrel ファイルを探索する
158
+ * @param {string} importedPath - import先の絶対パス
159
+ * @param {string} importerDir - import元のディレクトリ
160
+ * @returns {string|undefined} 見つかったbarrelファイルのパス
161
+ */
162
+ const findBarrelFile = (importedPath, importerDir) => {
163
+ const pathSegments = importedPath.split('/')
164
+ let currentPath = importedPath
165
+ let barrel = undefined
166
+
167
+ // ディレクトリ指定の場合、そのindex.tsを指していることは自明なので一階層上から探索
168
+ if (fs.existsSync(currentPath) && fs.statSync(currentPath).isDirectory()) {
169
+ pathSegments.pop()
170
+ currentPath = pathSegments.join('/')
171
+ }
172
+
173
+ while (pathSegments.length > 0) {
174
+ // 以下の場合は探索終了
175
+ // 1. root pathに到達した場合
176
+ // 2. import先がimport元の内部にある場合(同階層・サブディレクトリからのimport)
177
+ if (importerDir === rootPath || isImportedInsideImporter(importerDir, currentPath)) {
178
+ break
179
+ }
180
+
181
+ // 現在のパスにbarrelファイルがあるかチェック
182
+ const foundBarrel = TARGET_EXTS
183
+ .map(ext => `${currentPath}/index.${ext}`)
184
+ .find(filePath => fs.existsSync(filePath))
185
+
186
+ if (foundBarrel) {
187
+ barrel = foundBarrel
188
+ }
189
+
190
+ // 一階層上に移動
191
+ pathSegments.pop()
192
+ currentPath = pathSegments.join('/')
193
+ }
194
+
195
+ return barrel
196
+ }
90
197
 
91
198
  /**
92
199
  * @type {import('@typescript-eslint/utils').TSESLint.RuleModule<''>}
@@ -99,108 +206,78 @@ module.exports = {
99
206
  create(context) {
100
207
  const option = context.options[0] || {}
101
208
 
102
- if (option.ignores && option.ignores.some((i) => (new RegExp(i)).test(context.filename))) {
103
- return {}
209
+ // ignoresオプションでスキップ対象のファイルかチェック
210
+ if (option.ignores) {
211
+ const isIgnored = option.ignores.some(pattern =>
212
+ new RegExp(pattern).test(context.filename)
213
+ )
214
+ if (isIgnored) {
215
+ return {}
216
+ }
104
217
  }
105
218
 
106
- const dir = getParentDir(context.filename)
219
+ const importerDir = getParentDir(context.filename)
220
+
221
+ // このファイルに適用されるallowedImportsのキーを収集
107
222
  const targetAllowedImports = []
108
223
  if (option?.allowedImports) {
109
- for (const regex in option.allowedImports) {
110
- if ((new RegExp(regex)).test(context.filename)) {
111
- targetAllowedImports.push(regex)
224
+ for (const pattern in option.allowedImports) {
225
+ if (new RegExp(pattern).test(context.filename)) {
226
+ targetAllowedImports.push(pattern)
112
227
  }
113
228
  }
114
229
  }
115
230
 
116
231
  return {
117
232
  ImportDeclaration: (node) => {
118
- let isDenyPath = false
119
- let deniedModules = []
120
-
121
- for (const allowedKey of targetAllowedImports) {
122
- const allowedOption = option.allowedImports[allowedKey]
123
-
124
- for (const targetModule in allowedOption) {
125
- const actualTarget = targetModule[0] !== '.' ? targetModule : path.resolve(`${CWD}/${targetModule}`)
126
- let sourceValue = node.source.value
127
-
128
- if (actualTarget[0] === '/') {
129
- sourceValue = path.resolve(`${dir}/${sourceValue}`)
130
- }
131
-
132
- if (actualTarget !== sourceValue) {
133
- continue
134
- }
135
-
136
- let allowedModules = allowedOption[targetModule] || true
137
-
138
- if (!Array.isArray(allowedModules)) {
139
- isDenyPath = true
140
- deniedModules.push(true)
141
- } else {
142
- deniedModules.push(node.specifiers.map(pickImportedName).filter(i => allowedModules.indexOf(i) == -1))
143
- }
144
- }
145
- }
146
-
147
- if (
148
- isDenyPath && deniedModules[0] === true ||
149
- !isDenyPath && deniedModules.length === 1 && deniedModules[0].length === 0
150
- ) {
233
+ // allowedImportsチェック
234
+ const { shouldSkip, deniedModules } = checkAllowedImports(
235
+ node,
236
+ importerDir,
237
+ targetAllowedImports,
238
+ option.allowedImports || {}
239
+ )
240
+ if (shouldSkip) {
151
241
  return
152
242
  }
153
243
 
154
- let sourceValue = node.source.value
244
+ // import先のパスを絶対パスに変換
245
+ let importedPath = node.source.value
155
246
 
156
- if (sourceValue[0] === '.') {
157
- sourceValue = path.resolve(`${dir}/${sourceValue}`)
247
+ // 相対パスの場合、絶対パスに変換
248
+ if (importedPath[0] === '.') {
249
+ importedPath = path.resolve(`${importerDir}/${importedPath}`)
158
250
  }
159
251
 
160
- sourceValue = calculateAbsoluteImportPath(sourceValue)
252
+ // Path alias(@/, ~/など)を絶対パスに変換
253
+ importedPath = resolvePathAlias(importedPath)
161
254
 
162
- if (sourceValue[0] !== '/') {
255
+ // 絶対パスでない場合(node_modulesなど)はスキップ
256
+ if (importedPath[0] !== '/') {
163
257
  return
164
258
  }
165
259
 
166
- const sources = sourceValue.split('/')
260
+ // barrel ファイルを探索
261
+ const barrelPath = findBarrelFile(importedPath, importerDir)
167
262
 
168
- // HINT: directoryの場合、indexファイルからimportしていることは自明であるため、一階層上からチェックする
169
- if (fs.existsSync(sourceValue) && fs.statSync(sourceValue).isDirectory()) {
170
- sources.pop()
171
- sourceValue = sources.join('/')
172
- }
173
-
174
- let barrel = undefined
175
-
176
- while (sources.length > 0) {
177
- // HINT: 以下の場合は即終了
178
- // - import元以下のimportだった場合
179
- // - rootまで捜索した場合
180
- if (
181
- dir === rootPath ||
182
- dir.match(new RegExp(`^${sourceValue}`))
183
- ) {
184
- break
185
- }
186
-
187
- barrel = TARGET_EXTS.map((e) => `${sourceValue}/index.${e}`).find(findExistsSync) || barrel
188
-
189
- sources.pop()
190
- sourceValue = sources.join('/')
263
+ // barrel が見つからない、またはroot pathのindex.tsの場合はスキップ
264
+ if (!barrelPath || REGEX_ROOT_PATH.test(barrelPath)) {
265
+ return
191
266
  }
192
267
 
193
- if (barrel && !barrel.match(REGEX_ROOT_PATH)) {
194
- barrel = calculateReplacedImportPath(barrel)
195
- const noExt = barrel.replace(REGEX_INDEX_FILE, '')
196
- deniedModules = [...new Set(deniedModules.flat())]
197
-
198
- context.report({
199
- node,
200
- message: `${deniedModules.length ? `${deniedModules.join(', ')} は ${noExt} からimportしてください` : `${noExt} からimportするか、${barrel} のbarrelファイルを削除して直接import可能にしてください`}
201
- - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/require-barrel-import`,
202
- });
203
- }
268
+ // barrel パスをPath aliasに変換
269
+ const barrelWithAlias = convertToPathAlias(barrelPath)
270
+ const barrelDirWithAlias = barrelWithAlias.replace(REGEX_INDEX_FILE, '')
271
+ const uniqueDeniedModules = [...new Set(deniedModules.flat())]
272
+
273
+ // エラーを報告
274
+ context.report({
275
+ node,
276
+ message: uniqueDeniedModules.length
277
+ ? `${uniqueDeniedModules.join(', ')} は ${barrelDirWithAlias} からimportしてください`
278
+ : `${barrelDirWithAlias} からimportするか、${barrelWithAlias} のbarrelファイルを削除して直接import可能にしてください`
279
+ + '\n - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/require-barrel-import',
280
+ })
204
281
  },
205
282
  }
206
283
  },
@@ -47,11 +47,13 @@ ruleTester.run('a11y-heading-in-sectioning-content', rule, {
47
47
  { code: '<Reel as="aside"><div><Heading>hoge</Heading></div></Reel>' },
48
48
  { code: '<Sidebar as="nav"><div><Heading>hoge</Heading></div></Sidebar>' },
49
49
  { code: '<Stack as="section"><div><Heading>hoge</Heading></div></Stack>' },
50
+ { code: '<Scroller as="section"><div><Heading>hoge</Heading></div></Scroller>' },
50
51
  { code: '<HogeCenter forwardedAs="section"><div><Heading>hoge</Heading></div></HogeCenter>' },
51
52
  { code: '<HogeCluster forwardedAs="section"><div><Heading>hoge</Heading></div></HogeCluster>' },
52
53
  { code: '<HogeReel forwardedAs="aside"><div><Heading>hoge</Heading></div></HogeReel>' },
53
54
  { code: '<HogeSidebar forwardedAs="nav"><div><Heading>hoge</Heading></div></HogeSidebar>' },
54
55
  { code: '<HogeStack forwardedAs="section"><div><Heading>hoge</Heading></div></HogeStack>' },
56
+ { code: '<HogeScroller forwardedAs="section"><div><Heading>hoge</Heading></div></HogeScroller>' },
55
57
  { code: '<HogeBase as="aside"><Heading>hoge</Heading></HogeBase>' },
56
58
  { code: '<HogeBaseColumn forwardedAs="nav"><Heading>hoge</Heading></HogeBaseColumn>' },
57
59
  { code: '<HogeNav aria-label="any"><Any /></HogeNav>' },
@@ -23,7 +23,7 @@ const errorAsOnly = (tag) => `as属性のみを持つTextコンポーネント
23
23
 
24
24
  const errorUnnecessaryClassName = (className) => `Textコンポーネントの機能を使用していないため、ネイティブHTML要素(<span>)に置き換えてください。
25
25
  - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-text-component
26
- - 推奨: <span className="${className}">
26
+ - 推奨: <span${className ? ` className="${className}"` : ''}>
27
27
  - Textコンポーネントの機能(weight、size、color等)を使用しない場合は、直接HTML要素を使用することでシンプルになります`
28
28
 
29
29
  const errorUnnecessaryAsClassName = (tag) => `Textコンポーネントの機能を使用していないため、ネイティブHTML要素に置き換えてください。
@@ -31,12 +31,18 @@ const errorUnnecessaryAsClassName = (tag) => `Textコンポーネントの機能
31
31
  - <${tag}>要素にclassNameを移動してください
32
32
  - Textコンポーネントの機能(weight、size、color等)を使用しない場合は、直接HTML要素を使用することでシンプルになります`
33
33
 
34
- const errorConvertibleShr = (suggestion, convertible, as = null) => `classNameで指定されたshr-プレフィックスのクラスは、Textコンポーネントの属性に置き換えてください。
34
+ const errorConvertibleShr = (suggestion, convertible) => `classNameで指定されたshr-プレフィックスのクラスは、Textコンポーネントの属性に置き換えてください。
35
35
  - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-text-component
36
- - 推奨: <Text${as ? ` as="${as}"` : ''} ${suggestion}>
36
+ - 推奨: <Text ${suggestion}>
37
37
  - 変換可能なクラス: ${convertible}
38
38
  - shr-プレフィックスのクラスをTextの属性に置き換えることで、型安全性が向上し、意図がより明確になります`
39
39
 
40
+ const errorConvertibleShrWithSpread = (convertible) => `classNameで指定されたshr-プレフィックスのクラスは、Textコンポーネントの属性に置き換えてください。
41
+ - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-text-component
42
+ - 変換可能なクラス: ${convertible}
43
+ - spread attributes ({...props}) があるため自動修正できません。手動で修正してください
44
+ - shr-プレフィックスのクラスをTextの属性に置き換えることで、型安全性が向上し、意図がより明確になります`
45
+
40
46
  const errorConflictingProps = (convertible) => `Textコンポーネントの属性とclassNameで矛盾する指定があります。
41
47
  - 詳細: https://github.com/kufu/tamatebako/tree/master/packages/eslint-plugin-smarthr/rules/best-practice-for-text-component
42
48
  - 変換可能なクラス: ${convertible}
@@ -52,12 +58,6 @@ ruleTester.run('best-practice-for-text-component', rule, {
52
58
  { code: `<Text weight="bold">content</Text>` },
53
59
  { code: `<Text size="M">text</Text>` },
54
60
  { code: `<Text color="TEXT_GREY">text</Text>` },
55
- { code: `<Text leading="TIGHT">text</Text>` },
56
- { code: `<Text italic>text</Text>` },
57
- { code: `<Text whiteSpace="nowrap">text</Text>` },
58
- { code: `<Text maxLines={2}>text</Text>` },
59
- { code: `<Text styleType="blockTitle">text</Text>` },
60
- { code: `<Text icon={<Icon />}>text</Text>` },
61
61
  // as + スタイリング属性
62
62
  { code: `<Text as="p" weight="bold">content</Text>` },
63
63
  // Textのスタイリング属性 + className(変換不可能なクラスのみ)
@@ -65,28 +65,33 @@ ruleTester.run('best-practice-for-text-component', rule, {
65
65
  // className が変数や式の場合は静的解析できないのでスキップ
66
66
  { code: `<Text className={customClass}>text</Text>` },
67
67
  { code: `<Text className={\`custom-\${type}\`}>text</Text>` },
68
- { code: `<Text as="p" className={customClass}>text</Text>` },
69
- { code: `<Text size="M" className={customClass}>text</Text>` },
70
68
  // as が変数の場合
71
69
  { code: `<Text as={component}>text</Text>` },
70
+ // spread attributes は静的解析できないのでスキップ(変換不可能なclassNameの場合)
71
+ { code: `<Text {...props}>content</Text>` },
72
+ { code: `<Text {...props} className="custom">content</Text>` },
73
+ // key + Text専用属性(size等)→ valid
74
+ { code: `<Text key="item-1" size="M">text</Text>` },
75
+ { code: `<Text key={itemId} weight="bold">text</Text>` },
72
76
  ],
73
77
  invalid: [
74
78
  // パターン1-1: 属性なし
75
79
  { code: `<Text>content</Text>`, errors: [{ message: ERROR_NO_ATTRS }] },
76
80
 
81
+ // パターン1-1b: key属性のみ(key属性は無視してspanに変換)
82
+ { code: `<Text key="item-1">content</Text>`, output: `<span key="item-1">content</span>`, errors: [{ message: errorUnnecessaryClassName('') }] },
83
+
77
84
  // パターン1-2: as属性のみ
78
85
  { code: `<Text as="p">content</Text>`, output: `<p>content</p>`, errors: [{ message: errorAsOnly('p') }] },
79
- { code: `<Text as="div">content</Text>`, output: `<div>content</div>`, errors: [{ message: errorAsOnly('div') }] },
80
- { code: `<Text as="p">
81
- nested content
82
- </Text>`, output: `<p>
83
- nested content
84
- </p>`, errors: [{ message: errorAsOnly('p') }] },
86
+
87
+ // パターン1-2b: as + key属性のみ(key属性は無視してasタグに変換)
88
+ { code: `<Text as="p" key="item-1">content</Text>`, output: `<p key="item-1">content</p>`, errors: [{ message: errorAsOnly('p') }] },
85
89
 
86
90
  // パターン1-3: classNameのみ(変換不可能なクラスのみ)
87
91
  { code: `<Text className="custom">content</Text>`, output: `<span className="custom">content</span>`, errors: [{ message: errorUnnecessaryClassName('custom') }] },
88
- { code: `<Text className="custom-class another-class">text</Text>`, output: `<span className="custom-class another-class">text</span>`, errors: [{ message: errorUnnecessaryClassName('custom-class another-class') }] },
89
- { code: `<Text className="custom"><span>nested</span></Text>`, output: `<span className="custom"><span>nested</span></span>`, errors: [{ message: errorUnnecessaryClassName('custom') }] },
92
+
93
+ // パターン1-3b: className + key属性(key属性は保持される)
94
+ { code: `<Text className="custom" key="item-1">content</Text>`, output: `<span className="custom" key="item-1">content</span>`, errors: [{ message: errorUnnecessaryClassName('custom') }] },
90
95
 
91
96
  // パターン1-4: className + as(変換不可能なクラスのみ)
92
97
  { code: `<Text as="p" className="custom">content</Text>`, output: `<p className="custom">content</p>`, errors: [{ message: errorUnnecessaryAsClassName('p') }] },
@@ -97,12 +102,12 @@ ruleTester.run('best-practice-for-text-component', rule, {
97
102
  { code: `<Text className="shr-text-lg shr-font-bold shr-text-grey">text</Text>`, output: `<Text size="L" weight="bold" color="TEXT_GREY">text</Text>`, errors: [{ message: errorConvertibleShr('size="L" weight="bold" color="TEXT_GREY"', 'shr-text-lg, shr-font-bold, shr-text-grey') }] },
98
103
 
99
104
  // パターン2-2: className + as(すべて変換可能)
100
- { code: `<Text as="p" className="shr-text-sm">text</Text>`, output: `<Text as="p" size="S">text</Text>`, errors: [{ message: errorConvertibleShr('size="S"', 'shr-text-sm', 'p') }] },
101
- { code: `<Text as="p" className="shr-text-sm shr-font-bold">text</Text>`, output: `<Text as="p" size="S" weight="bold">text</Text>`, errors: [{ message: errorConvertibleShr('size="S" weight="bold"', 'shr-text-sm, shr-font-bold', 'p') }] },
105
+ { code: `<Text as="p" className="shr-text-sm">text</Text>`, output: `<Text as="p" size="S">text</Text>`, errors: [{ message: errorConvertibleShr('size="S"', 'shr-text-sm') }] },
106
+ { code: `<Text as="p" className="shr-text-sm shr-font-bold">text</Text>`, output: `<Text as="p" size="S" weight="bold">text</Text>`, errors: [{ message: errorConvertibleShr('size="S" weight="bold"', 'shr-text-sm, shr-font-bold') }] },
102
107
 
103
108
  // パターン2-3: 一部のみ変換可能
104
109
  { code: `<Text className="shr-text-sm custom-class">text</Text>`, output: `<Text size="S" className="custom-class">text</Text>`, errors: [{ message: errorConvertibleShr('size="S" className="custom-class"', 'shr-text-sm') }] },
105
- { code: `<Text as="p" className="shr-text-sm custom-class">text</Text>`, output: `<Text as="p" size="S" className="custom-class">text</Text>`, errors: [{ message: errorConvertibleShr('size="S" className="custom-class"', 'shr-text-sm', 'p') }] },
110
+ { code: `<Text as="p" className="shr-text-sm custom-class">text</Text>`, output: `<Text as="p" size="S" className="custom-class">text</Text>`, errors: [{ message: errorConvertibleShr('size="S" className="custom-class"', 'shr-text-sm') }] },
106
111
  { code: `<Text className="shr-text-lg shr-font-bold custom-one custom-two">text</Text>`, output: `<Text size="L" weight="bold" className="custom-one custom-two">text</Text>`, errors: [{ message: errorConvertibleShr('size="L" weight="bold" className="custom-one custom-two"', 'shr-text-lg, shr-font-bold') }] },
107
112
 
108
113
  // パターン2-4: shr-プレフィックスがあるが変換不可能なクラスのみ(spanに変換)
@@ -116,19 +121,15 @@ ruleTester.run('best-practice-for-text-component', rule, {
116
121
  { code: `<Text size="L" className="shr-text-sm shr-font-bold">text</Text>`, errors: [{ message: errorConflictingProps('shr-text-sm, shr-font-bold') }] },
117
122
  { code: `<Text size="M" className="shr-text-sm custom-class">text</Text>`, errors: [{ message: errorConflictingProps('shr-text-sm') }] },
118
123
 
119
- // 追加テスト: 各クラスの網羅性確認
120
- { code: `<Text className="shr-text-2xs">text</Text>`, output: `<Text size="XXS">text</Text>`, errors: [{ message: errorConvertibleShr('size="XXS"', 'shr-text-2xs') }] },
121
- { code: `<Text className="shr-text-2xl">text</Text>`, output: `<Text size="XXL">text</Text>`, errors: [{ message: errorConvertibleShr('size="XXL"', 'shr-text-2xl') }] },
122
- { code: `<Text className="shr-leading-none">text</Text>`, output: `<Text leading="NONE">text</Text>`, errors: [{ message: errorConvertibleShr('leading="NONE"', 'shr-leading-none') }] },
123
- { code: `<Text className="shr-text-white">text</Text>`, output: `<Text color="TEXT_WHITE">text</Text>`, errors: [{ message: errorConvertibleShr('color="TEXT_WHITE"', 'shr-text-white') }] },
124
- { code: `<Text className="shr-text-color-inherit">text</Text>`, output: `<Text color="inherit">text</Text>`, errors: [{ message: errorConvertibleShr('color="inherit"', 'shr-text-color-inherit') }] },
125
-
126
124
  // 追加テスト: 未知の属性が保持されることを確認
127
125
  { code: `<Text className="custom" id="foo">text</Text>`, output: `<span className="custom" id="foo">text</span>`, errors: [{ message: errorUnnecessaryClassName('custom') }] },
128
- { code: `<Text as="p" className="custom" id="foo">text</Text>`, output: `<p className="custom" id="foo">text</p>`, errors: [{ message: errorUnnecessaryAsClassName('p') }] },
129
- { code: `<Text id="foo" className="shr-text-sm">text</Text>`, output: `<Text size="S" id="foo">text</Text>`, errors: [{ message: errorConvertibleShr('size="S"', 'shr-text-sm') }] },
130
- { code: `<Text id="foo" className="shr-text-sm custom">text</Text>`, output: `<Text size="S" id="foo" className="custom">text</Text>`, errors: [{ message: errorConvertibleShr('size="S" className="custom"', 'shr-text-sm') }] },
131
- { code: `<Text as="p" id="foo" className="shr-text-sm">text</Text>`, output: `<Text as="p" size="S" id="foo">text</Text>`, errors: [{ message: errorConvertibleShr('size="S"', 'shr-text-sm', 'p') }] },
132
- { code: `<Text as="p" id="foo" onClick={handler} className="shr-text-sm custom">text</Text>`, output: `<Text as="p" size="S" id="foo" onClick={handler} className="custom">text</Text>`, errors: [{ message: errorConvertibleShr('size="S" className="custom"', 'shr-text-sm', 'p') }] },
126
+ { code: `<Text as="p" id="foo" className="shr-text-sm">text</Text>`, output: `<Text as="p" size="S" id="foo">text</Text>`, errors: [{ message: errorConvertibleShr('size="S"', 'shr-text-sm') }] },
127
+
128
+ // key属性対応テスト(追加のエッジケース)
129
+ { code: `<Text key="item-1" className="shr-text-sm">text</Text>`, output: `<Text size="S" key="item-1">text</Text>`, errors: [{ message: errorConvertibleShr('size="S"', 'shr-text-sm') }] },
130
+
131
+ // spread attributes + 変換可能なclassName(fixなし、警告のみ)
132
+ { code: `<Text {...props} className="shr-text-sm">text</Text>`, errors: [{ message: errorConvertibleShrWithSpread('shr-text-sm') }] },
133
+ { code: `<Text {...props} className="shr-text-sm shr-font-bold">text</Text>`, errors: [{ message: errorConvertibleShrWithSpread('shr-text-sm, shr-font-bold') }] },
133
134
  ]
134
135
  })