@wordpress/eslint-plugin 23.0.1-next.76cff8c98.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/configs/custom.js +2 -0
- package/package.json +6 -4
- package/rules/__tests__/components-no-missing-40px-size-prop.js +353 -0
- package/rules/__tests__/components-no-unsafe-button-disabled.js +235 -0
- package/rules/__tests__/no-setting-ds-tokens.js +46 -0
- package/rules/__tests__/no-unknown-ds-tokens.js +60 -0
- package/rules/components-no-missing-40px-size-prop.js +327 -0
- package/rules/components-no-unsafe-button-disabled.js +126 -0
- package/rules/no-setting-ds-tokens.js +27 -0
- package/rules/no-unknown-ds-tokens.js +101 -0
- package/utils/has-truthy-jsx-attribute.js +50 -0
- package/utils/index.js +2 -0
package/README.md
CHANGED
|
@@ -82,8 +82,10 @@ The granular rulesets will not define any environment globals. As such, if they
|
|
|
82
82
|
| [i18n-text-domain](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/i18n-text-domain.md) | Enforce passing valid text domains. | ✓ |
|
|
83
83
|
| [i18n-translator-comments](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/i18n-translator-comments.md) | Enforce adding translator comments. | ✓ |
|
|
84
84
|
| [no-base-control-with-label-without-id](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-base-control-with-label-without-id.md) | Disallow the usage of BaseControl component with a label prop set but omitting the id property. | ✓ |
|
|
85
|
+
| [components-no-missing-40px-size-prop](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/components-no-missing-40px-size-prop.md) | Disallow missing `__next40pxDefaultSize` prop on `@wordpress/components` components. | ✓ |
|
|
86
|
+
| [components-no-unsafe-button-disabled](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/components-no-unsafe-button-disabled.md) | Disallow using `disabled` on Button without `accessibleWhenDisabled`. | ✓ |
|
|
85
87
|
| [no-unguarded-get-range-at](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unguarded-get-range-at.md) | Disallow the usage of unguarded `getRangeAt` calls. | ✓ |
|
|
86
|
-
| [no-unsafe-wp-apis](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unsafe-wp-apis.md) | Disallow the usage of unsafe APIs from `@wordpress/*`
|
|
88
|
+
| [no-unsafe-wp-apis](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unsafe-wp-apis.md) | Disallow the usage of unsafe APIs from `@wordpress/*` packages | ✓ |
|
|
87
89
|
| [no-unused-vars-before-return](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unused-vars-before-return.md) | Disallow assigning variable values if unused before a return. | ✓ |
|
|
88
90
|
| [no-wp-process-env](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-wp-process-env.md) | Disallow legacy usage of WordPress variables via `process.env` like `process.env.SCRIPT_DEBUG`. | ✓ |
|
|
89
91
|
| [react-no-unsafe-timeout](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/react-no-unsafe-timeout.md) | Disallow unsafe `setTimeout` in component. | |
|
package/configs/custom.js
CHANGED
|
@@ -6,6 +6,8 @@ module.exports = {
|
|
|
6
6
|
'@wordpress/no-unguarded-get-range-at': 'error',
|
|
7
7
|
'@wordpress/no-global-active-element': 'error',
|
|
8
8
|
'@wordpress/no-global-get-selection': 'error',
|
|
9
|
+
'@wordpress/no-setting-ds-tokens': 'error',
|
|
10
|
+
'@wordpress/no-unknown-ds-tokens': 'error',
|
|
9
11
|
'@wordpress/no-unsafe-wp-apis': 'error',
|
|
10
12
|
'@wordpress/no-wp-process-env': 'error',
|
|
11
13
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wordpress/eslint-plugin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.0.0",
|
|
4
4
|
"description": "ESLint plugin for WordPress development.",
|
|
5
5
|
"author": "The WordPress Contributors",
|
|
6
6
|
"license": "GPL-2.0-or-later",
|
|
@@ -40,8 +40,9 @@
|
|
|
40
40
|
"@babel/eslint-parser": "7.25.7",
|
|
41
41
|
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
|
42
42
|
"@typescript-eslint/parser": "^6.4.1",
|
|
43
|
-
"@wordpress/babel-preset-default": "^8.
|
|
44
|
-
"@wordpress/prettier-config": "^4.
|
|
43
|
+
"@wordpress/babel-preset-default": "^8.38.0",
|
|
44
|
+
"@wordpress/prettier-config": "^4.38.0",
|
|
45
|
+
"@wordpress/theme": "^0.5.0",
|
|
45
46
|
"cosmiconfig": "^7.0.0",
|
|
46
47
|
"eslint-config-prettier": "^8.3.0",
|
|
47
48
|
"eslint-import-resolver-typescript": "^4.4.4",
|
|
@@ -57,6 +58,7 @@
|
|
|
57
58
|
"requireindex": "^1.2.0"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
61
|
+
"@types/eslint": "^8",
|
|
60
62
|
"@types/estree": "1.0.5"
|
|
61
63
|
},
|
|
62
64
|
"peerDependencies": {
|
|
@@ -76,5 +78,5 @@
|
|
|
76
78
|
"publishConfig": {
|
|
77
79
|
"access": "public"
|
|
78
80
|
},
|
|
79
|
-
"gitHead": "
|
|
81
|
+
"gitHead": "50c4c0f51e4797c217946ce42adfaa5eb026f33f"
|
|
80
82
|
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { RuleTester } from 'eslint';
|
|
2
|
+
import rule from '../components-no-missing-40px-size-prop';
|
|
3
|
+
|
|
4
|
+
const ruleTester = new RuleTester( {
|
|
5
|
+
parserOptions: {
|
|
6
|
+
sourceType: 'module',
|
|
7
|
+
ecmaVersion: 6,
|
|
8
|
+
ecmaFeatures: {
|
|
9
|
+
jsx: true,
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
} );
|
|
13
|
+
|
|
14
|
+
ruleTester.run( 'components-no-missing-40px-size-prop', rule, {
|
|
15
|
+
valid: [
|
|
16
|
+
// Component with __next40pxDefaultSize (boolean attribute)
|
|
17
|
+
{
|
|
18
|
+
code: `
|
|
19
|
+
import { Button } from '@wordpress/components';
|
|
20
|
+
<Button __next40pxDefaultSize />
|
|
21
|
+
`,
|
|
22
|
+
},
|
|
23
|
+
// Component with __next40pxDefaultSize={true}
|
|
24
|
+
{
|
|
25
|
+
code: `
|
|
26
|
+
import { InputControl } from '@wordpress/components';
|
|
27
|
+
<InputControl __next40pxDefaultSize={true} />
|
|
28
|
+
`,
|
|
29
|
+
},
|
|
30
|
+
// Component with non-default size prop
|
|
31
|
+
{
|
|
32
|
+
code: `
|
|
33
|
+
import { Button } from '@wordpress/components';
|
|
34
|
+
<Button size="small" />
|
|
35
|
+
`,
|
|
36
|
+
},
|
|
37
|
+
// Component with size="compact"
|
|
38
|
+
{
|
|
39
|
+
code: `
|
|
40
|
+
import { SelectControl } from '@wordpress/components';
|
|
41
|
+
<SelectControl size="compact" />
|
|
42
|
+
`,
|
|
43
|
+
},
|
|
44
|
+
// Component from @wordpress/ui (should not be checked)
|
|
45
|
+
{
|
|
46
|
+
code: `
|
|
47
|
+
import { Button } from '@wordpress/ui';
|
|
48
|
+
<Button />
|
|
49
|
+
`,
|
|
50
|
+
},
|
|
51
|
+
// Local component (should not be checked)
|
|
52
|
+
{
|
|
53
|
+
code: `
|
|
54
|
+
const Button = () => <button />;
|
|
55
|
+
<Button />
|
|
56
|
+
`,
|
|
57
|
+
},
|
|
58
|
+
// Component from another package (should not be checked)
|
|
59
|
+
{
|
|
60
|
+
code: `
|
|
61
|
+
import { Button } from 'some-other-package';
|
|
62
|
+
<Button />
|
|
63
|
+
`,
|
|
64
|
+
},
|
|
65
|
+
// Aliased import with correct prop
|
|
66
|
+
{
|
|
67
|
+
code: `
|
|
68
|
+
import { Button as WPButton } from '@wordpress/components';
|
|
69
|
+
<WPButton __next40pxDefaultSize />
|
|
70
|
+
`,
|
|
71
|
+
},
|
|
72
|
+
// FormFileUpload with render prop (special case)
|
|
73
|
+
{
|
|
74
|
+
code: `
|
|
75
|
+
import { FormFileUpload } from '@wordpress/components';
|
|
76
|
+
<FormFileUpload render={({ open }) => <button onClick={open}>Upload</button>} />
|
|
77
|
+
`,
|
|
78
|
+
},
|
|
79
|
+
// FormFileUpload with __next40pxDefaultSize
|
|
80
|
+
{
|
|
81
|
+
code: `
|
|
82
|
+
import { FormFileUpload } from '@wordpress/components';
|
|
83
|
+
<FormFileUpload __next40pxDefaultSize />
|
|
84
|
+
`,
|
|
85
|
+
},
|
|
86
|
+
// Component with dynamic size prop (assumes it could be non-default)
|
|
87
|
+
{
|
|
88
|
+
code: `
|
|
89
|
+
import { Button } from '@wordpress/components';
|
|
90
|
+
<Button size={buttonSize} />
|
|
91
|
+
`,
|
|
92
|
+
},
|
|
93
|
+
// Button with variant="link" (doesn't need __next40pxDefaultSize)
|
|
94
|
+
{
|
|
95
|
+
code: `
|
|
96
|
+
import { Button } from '@wordpress/components';
|
|
97
|
+
<Button variant="link" />
|
|
98
|
+
`,
|
|
99
|
+
},
|
|
100
|
+
// Non-targeted component (should not be checked)
|
|
101
|
+
{
|
|
102
|
+
code: `
|
|
103
|
+
import { Modal } from '@wordpress/components';
|
|
104
|
+
<Modal />
|
|
105
|
+
`,
|
|
106
|
+
},
|
|
107
|
+
// All targeted components with __next40pxDefaultSize
|
|
108
|
+
{
|
|
109
|
+
code: `
|
|
110
|
+
import {
|
|
111
|
+
BorderBoxControl,
|
|
112
|
+
BorderControl,
|
|
113
|
+
BoxControl,
|
|
114
|
+
ComboboxControl,
|
|
115
|
+
CustomSelectControl,
|
|
116
|
+
FontAppearanceControl,
|
|
117
|
+
FontFamilyControl,
|
|
118
|
+
FontSizePicker,
|
|
119
|
+
FormTokenField,
|
|
120
|
+
InputControl,
|
|
121
|
+
LetterSpacingControl,
|
|
122
|
+
LineHeightControl,
|
|
123
|
+
NumberControl,
|
|
124
|
+
RangeControl,
|
|
125
|
+
SelectControl,
|
|
126
|
+
TextControl,
|
|
127
|
+
ToggleGroupControl,
|
|
128
|
+
UnitControl,
|
|
129
|
+
} from '@wordpress/components';
|
|
130
|
+
<>
|
|
131
|
+
<BorderBoxControl __next40pxDefaultSize />
|
|
132
|
+
<BorderControl __next40pxDefaultSize />
|
|
133
|
+
<BoxControl __next40pxDefaultSize />
|
|
134
|
+
<ComboboxControl __next40pxDefaultSize />
|
|
135
|
+
<CustomSelectControl __next40pxDefaultSize />
|
|
136
|
+
<FontAppearanceControl __next40pxDefaultSize />
|
|
137
|
+
<FontFamilyControl __next40pxDefaultSize />
|
|
138
|
+
<FontSizePicker __next40pxDefaultSize />
|
|
139
|
+
<FormTokenField __next40pxDefaultSize />
|
|
140
|
+
<InputControl __next40pxDefaultSize />
|
|
141
|
+
<LetterSpacingControl __next40pxDefaultSize />
|
|
142
|
+
<LineHeightControl __next40pxDefaultSize />
|
|
143
|
+
<NumberControl __next40pxDefaultSize />
|
|
144
|
+
<RangeControl __next40pxDefaultSize />
|
|
145
|
+
<SelectControl __next40pxDefaultSize />
|
|
146
|
+
<TextControl __next40pxDefaultSize />
|
|
147
|
+
<ToggleGroupControl __next40pxDefaultSize />
|
|
148
|
+
<UnitControl __next40pxDefaultSize />
|
|
149
|
+
</>
|
|
150
|
+
`,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
invalid: [
|
|
154
|
+
// Button without __next40pxDefaultSize
|
|
155
|
+
{
|
|
156
|
+
code: `
|
|
157
|
+
import { Button } from '@wordpress/components';
|
|
158
|
+
<Button />
|
|
159
|
+
`,
|
|
160
|
+
errors: [
|
|
161
|
+
{
|
|
162
|
+
messageId: 'missingProp',
|
|
163
|
+
data: { component: 'Button' },
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
// InputControl without __next40pxDefaultSize
|
|
168
|
+
{
|
|
169
|
+
code: `
|
|
170
|
+
import { InputControl } from '@wordpress/components';
|
|
171
|
+
<InputControl value={value} onChange={onChange} />
|
|
172
|
+
`,
|
|
173
|
+
errors: [
|
|
174
|
+
{
|
|
175
|
+
messageId: 'missingProp',
|
|
176
|
+
data: { component: 'InputControl' },
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
// Component with __next40pxDefaultSize={false}
|
|
181
|
+
{
|
|
182
|
+
code: `
|
|
183
|
+
import { SelectControl } from '@wordpress/components';
|
|
184
|
+
<SelectControl __next40pxDefaultSize={false} />
|
|
185
|
+
`,
|
|
186
|
+
errors: [
|
|
187
|
+
{
|
|
188
|
+
messageId: 'missingProp',
|
|
189
|
+
data: { component: 'SelectControl' },
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
// Component with size="default" (should still require __next40pxDefaultSize)
|
|
194
|
+
{
|
|
195
|
+
code: `
|
|
196
|
+
import { Button } from '@wordpress/components';
|
|
197
|
+
<Button size="default" />
|
|
198
|
+
`,
|
|
199
|
+
errors: [
|
|
200
|
+
{
|
|
201
|
+
messageId: 'missingProp',
|
|
202
|
+
data: { component: 'Button' },
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
// Aliased import without __next40pxDefaultSize
|
|
207
|
+
{
|
|
208
|
+
code: `
|
|
209
|
+
import { TextControl as MyTextControl } from '@wordpress/components';
|
|
210
|
+
<MyTextControl />
|
|
211
|
+
`,
|
|
212
|
+
errors: [
|
|
213
|
+
{
|
|
214
|
+
messageId: 'missingProp',
|
|
215
|
+
data: { component: 'TextControl' },
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
// FormFileUpload without __next40pxDefaultSize or render
|
|
220
|
+
{
|
|
221
|
+
code: `
|
|
222
|
+
import { FormFileUpload } from '@wordpress/components';
|
|
223
|
+
<FormFileUpload onChange={handleChange} />
|
|
224
|
+
`,
|
|
225
|
+
errors: [
|
|
226
|
+
{
|
|
227
|
+
messageId: 'missingPropFormFileUpload',
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
// Multiple components, some invalid
|
|
232
|
+
{
|
|
233
|
+
code: `
|
|
234
|
+
import { Button, InputControl } from '@wordpress/components';
|
|
235
|
+
<>
|
|
236
|
+
<Button __next40pxDefaultSize />
|
|
237
|
+
<InputControl />
|
|
238
|
+
</>
|
|
239
|
+
`,
|
|
240
|
+
errors: [
|
|
241
|
+
{
|
|
242
|
+
messageId: 'missingProp',
|
|
243
|
+
data: { component: 'InputControl' },
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
// Multiple invalid components
|
|
248
|
+
{
|
|
249
|
+
code: `
|
|
250
|
+
import { Button, SelectControl } from '@wordpress/components';
|
|
251
|
+
<>
|
|
252
|
+
<Button />
|
|
253
|
+
<SelectControl />
|
|
254
|
+
</>
|
|
255
|
+
`,
|
|
256
|
+
errors: [
|
|
257
|
+
{
|
|
258
|
+
messageId: 'missingProp',
|
|
259
|
+
data: { component: 'Button' },
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
messageId: 'missingProp',
|
|
263
|
+
data: { component: 'SelectControl' },
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
// Relative import with checkLocalImports enabled
|
|
268
|
+
{
|
|
269
|
+
code: `
|
|
270
|
+
import { Button } from '../button';
|
|
271
|
+
<Button />
|
|
272
|
+
`,
|
|
273
|
+
options: [ { checkLocalImports: true } ],
|
|
274
|
+
errors: [
|
|
275
|
+
{
|
|
276
|
+
messageId: 'missingProp',
|
|
277
|
+
data: { component: 'Button' },
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
// Default import from input-control path with checkLocalImports enabled
|
|
282
|
+
{
|
|
283
|
+
code: `
|
|
284
|
+
import InputControl from '../input-control';
|
|
285
|
+
<InputControl />
|
|
286
|
+
`,
|
|
287
|
+
options: [ { checkLocalImports: true } ],
|
|
288
|
+
errors: [
|
|
289
|
+
{
|
|
290
|
+
messageId: 'missingProp',
|
|
291
|
+
data: { component: 'InputControl' },
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
} );
|
|
297
|
+
|
|
298
|
+
// Additional tests for checkLocalImports option
|
|
299
|
+
ruleTester.run(
|
|
300
|
+
'components-no-missing-40px-size-prop (checkLocalImports)',
|
|
301
|
+
rule,
|
|
302
|
+
{
|
|
303
|
+
valid: [
|
|
304
|
+
// Relative import with correct props
|
|
305
|
+
{
|
|
306
|
+
code: `
|
|
307
|
+
import { Button } from '../button';
|
|
308
|
+
<Button __next40pxDefaultSize />
|
|
309
|
+
`,
|
|
310
|
+
options: [ { checkLocalImports: true } ],
|
|
311
|
+
},
|
|
312
|
+
// Default import with correct props
|
|
313
|
+
{
|
|
314
|
+
code: `
|
|
315
|
+
import InputControl from './input-control';
|
|
316
|
+
<InputControl __next40pxDefaultSize />
|
|
317
|
+
`,
|
|
318
|
+
options: [ { checkLocalImports: true } ],
|
|
319
|
+
},
|
|
320
|
+
// Relative import with non-default size
|
|
321
|
+
{
|
|
322
|
+
code: `
|
|
323
|
+
import { Button } from '../button';
|
|
324
|
+
<Button size="small" />
|
|
325
|
+
`,
|
|
326
|
+
options: [ { checkLocalImports: true } ],
|
|
327
|
+
},
|
|
328
|
+
// Relative import without checkLocalImports (should not be checked)
|
|
329
|
+
{
|
|
330
|
+
code: `
|
|
331
|
+
import { Button } from '../button';
|
|
332
|
+
<Button />
|
|
333
|
+
`,
|
|
334
|
+
},
|
|
335
|
+
// Default import without checkLocalImports (should not be checked)
|
|
336
|
+
{
|
|
337
|
+
code: `
|
|
338
|
+
import InputControl from '../input-control';
|
|
339
|
+
<InputControl />
|
|
340
|
+
`,
|
|
341
|
+
},
|
|
342
|
+
// FormFileUpload relative import with render prop
|
|
343
|
+
{
|
|
344
|
+
code: `
|
|
345
|
+
import { FormFileUpload } from '../form-file-upload';
|
|
346
|
+
<FormFileUpload render={({ open }) => <button onClick={open}>Upload</button>} />
|
|
347
|
+
`,
|
|
348
|
+
options: [ { checkLocalImports: true } ],
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
invalid: [],
|
|
352
|
+
}
|
|
353
|
+
);
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { RuleTester } from 'eslint';
|
|
2
|
+
import rule from '../components-no-unsafe-button-disabled';
|
|
3
|
+
|
|
4
|
+
const ruleTester = new RuleTester( {
|
|
5
|
+
parserOptions: {
|
|
6
|
+
sourceType: 'module',
|
|
7
|
+
ecmaVersion: 6,
|
|
8
|
+
ecmaFeatures: {
|
|
9
|
+
jsx: true,
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
} );
|
|
13
|
+
|
|
14
|
+
ruleTester.run( 'components-no-unsafe-button-disabled', rule, {
|
|
15
|
+
valid: [
|
|
16
|
+
// Button with both disabled and accessibleWhenDisabled
|
|
17
|
+
{
|
|
18
|
+
code: `
|
|
19
|
+
import { Button } from '@wordpress/components';
|
|
20
|
+
<Button disabled accessibleWhenDisabled />
|
|
21
|
+
`,
|
|
22
|
+
},
|
|
23
|
+
// Button with accessibleWhenDisabled={true}
|
|
24
|
+
{
|
|
25
|
+
code: `
|
|
26
|
+
import { Button } from '@wordpress/components';
|
|
27
|
+
<Button disabled accessibleWhenDisabled={true} />
|
|
28
|
+
`,
|
|
29
|
+
},
|
|
30
|
+
// Button with accessibleWhenDisabled={false}
|
|
31
|
+
{
|
|
32
|
+
code: `
|
|
33
|
+
import { Button } from '@wordpress/components';
|
|
34
|
+
<Button disabled accessibleWhenDisabled={false} />
|
|
35
|
+
`,
|
|
36
|
+
},
|
|
37
|
+
// Button with accessibleWhenDisabled={someVar}
|
|
38
|
+
{
|
|
39
|
+
code: `
|
|
40
|
+
import { Button } from '@wordpress/components';
|
|
41
|
+
<Button disabled accessibleWhenDisabled={someVar} />
|
|
42
|
+
`,
|
|
43
|
+
},
|
|
44
|
+
// Button with accessibleWhenDisabled={false} should error (handled in invalid)
|
|
45
|
+
// Button with disabled={false} should not require accessibleWhenDisabled
|
|
46
|
+
{
|
|
47
|
+
code: `
|
|
48
|
+
import { Button } from '@wordpress/components';
|
|
49
|
+
<Button disabled={false} />
|
|
50
|
+
`,
|
|
51
|
+
},
|
|
52
|
+
// Button with disabled={someVar} and accessibleWhenDisabled={someVar}
|
|
53
|
+
{
|
|
54
|
+
code: `
|
|
55
|
+
import { Button } from '@wordpress/components';
|
|
56
|
+
<Button disabled={isDisabled} accessibleWhenDisabled={someVar} />
|
|
57
|
+
`,
|
|
58
|
+
},
|
|
59
|
+
// Button without disabled prop
|
|
60
|
+
{
|
|
61
|
+
code: `
|
|
62
|
+
import { Button } from '@wordpress/components';
|
|
63
|
+
<Button onClick={handleClick} />
|
|
64
|
+
`,
|
|
65
|
+
},
|
|
66
|
+
// Button from @wordpress/ui (should not be checked)
|
|
67
|
+
{
|
|
68
|
+
code: `
|
|
69
|
+
import { Button } from '@wordpress/ui';
|
|
70
|
+
<Button disabled />
|
|
71
|
+
`,
|
|
72
|
+
},
|
|
73
|
+
// Local Button component (should not be checked)
|
|
74
|
+
{
|
|
75
|
+
code: `
|
|
76
|
+
const Button = () => <button />;
|
|
77
|
+
<Button disabled />
|
|
78
|
+
`,
|
|
79
|
+
},
|
|
80
|
+
// Button from another package (should not be checked)
|
|
81
|
+
{
|
|
82
|
+
code: `
|
|
83
|
+
import { Button } from 'some-other-package';
|
|
84
|
+
<Button disabled />
|
|
85
|
+
`,
|
|
86
|
+
},
|
|
87
|
+
// Aliased import with correct props
|
|
88
|
+
{
|
|
89
|
+
code: `
|
|
90
|
+
import { Button as WPButton } from '@wordpress/components';
|
|
91
|
+
<WPButton disabled accessibleWhenDisabled />
|
|
92
|
+
`,
|
|
93
|
+
},
|
|
94
|
+
// Non-Button component with disabled (should not be checked)
|
|
95
|
+
{
|
|
96
|
+
code: `
|
|
97
|
+
import { TextControl } from '@wordpress/components';
|
|
98
|
+
<TextControl disabled />
|
|
99
|
+
`,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
invalid: [
|
|
103
|
+
// Button with disabled but no accessibleWhenDisabled
|
|
104
|
+
{
|
|
105
|
+
code: `
|
|
106
|
+
import { Button } from '@wordpress/components';
|
|
107
|
+
<Button disabled />
|
|
108
|
+
`,
|
|
109
|
+
errors: [
|
|
110
|
+
{
|
|
111
|
+
messageId: 'missingAccessibleWhenDisabled',
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
// Button with disabled={someVar} but no accessibleWhenDisabled
|
|
116
|
+
{
|
|
117
|
+
code: `
|
|
118
|
+
import { Button } from '@wordpress/components';
|
|
119
|
+
<Button disabled={isDisabled} />
|
|
120
|
+
`,
|
|
121
|
+
errors: [
|
|
122
|
+
{
|
|
123
|
+
messageId: 'missingAccessibleWhenDisabled',
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
// Button with disabled={true} but no accessibleWhenDisabled
|
|
128
|
+
{
|
|
129
|
+
code: `
|
|
130
|
+
import { Button } from '@wordpress/components';
|
|
131
|
+
<Button disabled={true} />
|
|
132
|
+
`,
|
|
133
|
+
errors: [
|
|
134
|
+
{
|
|
135
|
+
messageId: 'missingAccessibleWhenDisabled',
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
// Aliased import without accessibleWhenDisabled
|
|
140
|
+
{
|
|
141
|
+
code: `
|
|
142
|
+
import { Button as MyButton } from '@wordpress/components';
|
|
143
|
+
<MyButton disabled />
|
|
144
|
+
`,
|
|
145
|
+
errors: [
|
|
146
|
+
{
|
|
147
|
+
messageId: 'missingAccessibleWhenDisabled',
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
// Multiple Buttons, one invalid
|
|
152
|
+
{
|
|
153
|
+
code: `
|
|
154
|
+
import { Button } from '@wordpress/components';
|
|
155
|
+
<>
|
|
156
|
+
<Button disabled accessibleWhenDisabled />
|
|
157
|
+
<Button disabled />
|
|
158
|
+
</>
|
|
159
|
+
`,
|
|
160
|
+
errors: [
|
|
161
|
+
{
|
|
162
|
+
messageId: 'missingAccessibleWhenDisabled',
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
} );
|
|
168
|
+
|
|
169
|
+
// Additional tests for checkLocalImports option
|
|
170
|
+
ruleTester.run(
|
|
171
|
+
'components-no-unsafe-button-disabled (checkLocalImports)',
|
|
172
|
+
rule,
|
|
173
|
+
{
|
|
174
|
+
valid: [
|
|
175
|
+
// Relative import with correct props
|
|
176
|
+
{
|
|
177
|
+
code: `
|
|
178
|
+
import { Button } from '../button';
|
|
179
|
+
<Button disabled accessibleWhenDisabled />
|
|
180
|
+
`,
|
|
181
|
+
options: [ { checkLocalImports: true } ],
|
|
182
|
+
},
|
|
183
|
+
// Default import with correct props
|
|
184
|
+
{
|
|
185
|
+
code: `
|
|
186
|
+
import Button from './button';
|
|
187
|
+
<Button disabled accessibleWhenDisabled />
|
|
188
|
+
`,
|
|
189
|
+
options: [ { checkLocalImports: true } ],
|
|
190
|
+
},
|
|
191
|
+
// Relative import without checkLocalImports (should not be checked)
|
|
192
|
+
{
|
|
193
|
+
code: `
|
|
194
|
+
import { Button } from '../button';
|
|
195
|
+
<Button disabled />
|
|
196
|
+
`,
|
|
197
|
+
},
|
|
198
|
+
// Default import without checkLocalImports (should not be checked)
|
|
199
|
+
{
|
|
200
|
+
code: `
|
|
201
|
+
import Button from '../button';
|
|
202
|
+
<Button disabled />
|
|
203
|
+
`,
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
invalid: [
|
|
207
|
+
// Relative import with checkLocalImports enabled
|
|
208
|
+
{
|
|
209
|
+
code: `
|
|
210
|
+
import { Button } from '../button';
|
|
211
|
+
<Button disabled />
|
|
212
|
+
`,
|
|
213
|
+
options: [ { checkLocalImports: true } ],
|
|
214
|
+
errors: [
|
|
215
|
+
{
|
|
216
|
+
messageId: 'missingAccessibleWhenDisabled',
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
// Default import from button path with checkLocalImports enabled
|
|
221
|
+
{
|
|
222
|
+
code: `
|
|
223
|
+
import Button from '../button';
|
|
224
|
+
<Button disabled />
|
|
225
|
+
`,
|
|
226
|
+
options: [ { checkLocalImports: true } ],
|
|
227
|
+
errors: [
|
|
228
|
+
{
|
|
229
|
+
messageId: 'missingAccessibleWhenDisabled',
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
}
|
|
235
|
+
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { RuleTester } from 'eslint';
|
|
2
|
+
import rule from '../no-setting-ds-tokens';
|
|
3
|
+
|
|
4
|
+
const ruleTester = new RuleTester( {
|
|
5
|
+
parserOptions: {
|
|
6
|
+
ecmaVersion: 6,
|
|
7
|
+
ecmaFeatures: {
|
|
8
|
+
jsx: true,
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
} );
|
|
12
|
+
|
|
13
|
+
ruleTester.run( 'no-setting-ds-tokens', rule, {
|
|
14
|
+
valid: [
|
|
15
|
+
{
|
|
16
|
+
code: `<div style={ { '--my-custom-prop': 'value' } } />`,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
code: `<div style={ { color: 'var(--wpds-color-fg-content-neutral)' } } />`,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
code: `<div style={ { '--other-prefix-token': '10px' } } />`,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
code: `<div style={ { margin: '10px' } } />`,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
invalid: [
|
|
29
|
+
{
|
|
30
|
+
code: `<div style={ { '--wpds-color-fg-content-neutral': 'red' } } />`,
|
|
31
|
+
errors: [
|
|
32
|
+
{
|
|
33
|
+
messageId: 'disallowedSet',
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
code: `<div style={ { '--wpds-font-size-md': '10px', color: 'blue' } } />`,
|
|
39
|
+
errors: [
|
|
40
|
+
{
|
|
41
|
+
messageId: 'disallowedSet',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
} );
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { RuleTester } from 'eslint';
|
|
2
|
+
import rule from '../no-unknown-ds-tokens';
|
|
3
|
+
|
|
4
|
+
const ruleTester = new RuleTester( {
|
|
5
|
+
parserOptions: {
|
|
6
|
+
ecmaVersion: 6,
|
|
7
|
+
ecmaFeatures: {
|
|
8
|
+
jsx: true,
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
} );
|
|
12
|
+
|
|
13
|
+
ruleTester.run( 'no-unknown-ds-tokens', rule, {
|
|
14
|
+
valid: [
|
|
15
|
+
{
|
|
16
|
+
code: `<div style={ { color: 'var(--my-custom-prop)' } } />`,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
code: `<div style={ { color: 'blue' } } />`,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
code: `<div style={ { color: 'var(--other-prefix-token)' } } />`,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
code: `<div style={ { color: 'var(--wpds-color-fg-content-neutral)' } } />`,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
code: '<div style={ { color: `var(--wpds-color-fg-content-neutral)` } } />',
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
invalid: [
|
|
32
|
+
{
|
|
33
|
+
code: `<div style={ { color: 'var(--wpds-nonexistent-token)' } } />`,
|
|
34
|
+
errors: [
|
|
35
|
+
{
|
|
36
|
+
messageId: 'onlyKnownTokens',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
code: `<div style={ { color: 'var(--wpds-fake-color, var(--wpds-also-fake))' } } />`,
|
|
42
|
+
errors: [
|
|
43
|
+
{
|
|
44
|
+
messageId: 'onlyKnownTokens',
|
|
45
|
+
data: {
|
|
46
|
+
tokenNames: "'--wpds-fake-color', '--wpds-also-fake'",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
code: '<div style={ { color: `var(--wpds-nonexistent)` } } />',
|
|
53
|
+
errors: [
|
|
54
|
+
{
|
|
55
|
+
messageId: 'onlyKnownTokens',
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
} );
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
const { hasTruthyJsxAttribute } = require( '../utils' );
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Enforces that specific components from @wordpress/components include the
|
|
5
|
+
* `__next40pxDefaultSize` prop.
|
|
6
|
+
*
|
|
7
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Components that require the __next40pxDefaultSize prop.
|
|
12
|
+
* These can be exempted if they have a non-default `size` prop.
|
|
13
|
+
*/
|
|
14
|
+
const COMPONENTS_REQUIRING_40PX = new Set( [
|
|
15
|
+
'BorderBoxControl',
|
|
16
|
+
'BorderControl',
|
|
17
|
+
'BoxControl',
|
|
18
|
+
'Button',
|
|
19
|
+
'ClipboardButton',
|
|
20
|
+
'ComboboxControl',
|
|
21
|
+
'CustomSelectControl',
|
|
22
|
+
'FontAppearanceControl',
|
|
23
|
+
'FontFamilyControl',
|
|
24
|
+
'FontSizePicker',
|
|
25
|
+
'FormTokenField',
|
|
26
|
+
'IconButton',
|
|
27
|
+
'InputControl',
|
|
28
|
+
'LetterSpacingControl',
|
|
29
|
+
'LineHeightControl',
|
|
30
|
+
'NumberControl',
|
|
31
|
+
'Radio',
|
|
32
|
+
'RangeControl',
|
|
33
|
+
'SelectControl',
|
|
34
|
+
'TextControl',
|
|
35
|
+
'TreeSelect',
|
|
36
|
+
'ToggleGroupControl',
|
|
37
|
+
'UnitControl',
|
|
38
|
+
] );
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Components that can use the `render` prop as an alternative to __next40pxDefaultSize.
|
|
42
|
+
*/
|
|
43
|
+
const COMPONENTS_WITH_RENDER_EXEMPTION = new Set( [ 'FormFileUpload' ] );
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* All tracked component names for path-based detection.
|
|
47
|
+
*/
|
|
48
|
+
const ALL_TRACKED_COMPONENTS = new Set( [
|
|
49
|
+
...COMPONENTS_REQUIRING_40PX,
|
|
50
|
+
...COMPONENTS_WITH_RENDER_EXEMPTION,
|
|
51
|
+
] );
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
meta: {
|
|
55
|
+
type: 'problem',
|
|
56
|
+
schema: [
|
|
57
|
+
{
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
checkLocalImports: {
|
|
61
|
+
type: 'boolean',
|
|
62
|
+
description:
|
|
63
|
+
'When true, also checks components imported from relative paths (for use inside @wordpress/components package).',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
messages: {
|
|
70
|
+
missingProp:
|
|
71
|
+
'{{ component }} should have the `__next40pxDefaultSize` prop when using the default size.',
|
|
72
|
+
missingPropFormFileUpload:
|
|
73
|
+
'FormFileUpload should have the `__next40pxDefaultSize` prop to opt-in to the new default size.',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
create( context ) {
|
|
77
|
+
const checkLocalImports =
|
|
78
|
+
context.options[ 0 ]?.checkLocalImports ?? false;
|
|
79
|
+
|
|
80
|
+
// Track local names of components imported from @wordpress/components
|
|
81
|
+
// Map: localName -> importedName
|
|
82
|
+
const trackedImports = new Map();
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if the import source should be tracked.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} source - The import source path
|
|
88
|
+
* @return {boolean} Whether to track imports from this source
|
|
89
|
+
*/
|
|
90
|
+
function shouldTrackImportSource( source ) {
|
|
91
|
+
if ( source === '@wordpress/components' ) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// When checkLocalImports is enabled, also track relative imports
|
|
96
|
+
if ( checkLocalImports ) {
|
|
97
|
+
return source.startsWith( '.' ) || source.startsWith( '/' );
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Try to infer component name from import path.
|
|
105
|
+
* e.g., '../button' -> 'Button', '../input-control' -> 'InputControl'
|
|
106
|
+
*
|
|
107
|
+
* @param {string} source - The import source path
|
|
108
|
+
* @return {string|null} The inferred component name or null
|
|
109
|
+
*/
|
|
110
|
+
function inferComponentNameFromPath( source ) {
|
|
111
|
+
// Get the last segment of the path
|
|
112
|
+
const lastSegment = source.split( '/' ).pop();
|
|
113
|
+
if ( ! lastSegment ) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Convert kebab-case to PascalCase
|
|
118
|
+
const pascalCase = lastSegment
|
|
119
|
+
.split( '-' )
|
|
120
|
+
.map(
|
|
121
|
+
( part ) => part.charAt( 0 ).toUpperCase() + part.slice( 1 )
|
|
122
|
+
)
|
|
123
|
+
.join( '' );
|
|
124
|
+
|
|
125
|
+
// Check if it's one of our tracked components
|
|
126
|
+
if ( ALL_TRACKED_COMPONENTS.has( pascalCase ) ) {
|
|
127
|
+
return pascalCase;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if the `size` prop has a non-default value.
|
|
135
|
+
*
|
|
136
|
+
* @param {Array} attributes - JSX attributes array
|
|
137
|
+
* @return {boolean} Whether size has a non-default value
|
|
138
|
+
*/
|
|
139
|
+
function hasNonDefaultSize( attributes ) {
|
|
140
|
+
const sizeAttr = attributes.find(
|
|
141
|
+
( a ) =>
|
|
142
|
+
a.type === 'JSXAttribute' &&
|
|
143
|
+
a.name &&
|
|
144
|
+
a.name.name === 'size'
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if ( ! sizeAttr ) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// String value like `size="small"` or `size="compact"`
|
|
152
|
+
if (
|
|
153
|
+
sizeAttr.value &&
|
|
154
|
+
sizeAttr.value.type === 'Literal' &&
|
|
155
|
+
typeof sizeAttr.value.value === 'string'
|
|
156
|
+
) {
|
|
157
|
+
return sizeAttr.value.value !== 'default';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Expression - could be non-default, so don't report
|
|
161
|
+
if (
|
|
162
|
+
sizeAttr.value &&
|
|
163
|
+
sizeAttr.value.type === 'JSXExpressionContainer'
|
|
164
|
+
) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Check if the `render` prop exists.
|
|
173
|
+
*
|
|
174
|
+
* @param {Array} attributes - JSX attributes array
|
|
175
|
+
* @return {boolean} Whether render prop exists
|
|
176
|
+
*/
|
|
177
|
+
function hasRenderProp( attributes ) {
|
|
178
|
+
return attributes.some(
|
|
179
|
+
( a ) =>
|
|
180
|
+
a.type === 'JSXAttribute' &&
|
|
181
|
+
a.name &&
|
|
182
|
+
a.name.name === 'render'
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Check if the `variant` prop has the value "link".
|
|
188
|
+
* Button with variant="link" doesn't need __next40pxDefaultSize.
|
|
189
|
+
*
|
|
190
|
+
* @param {Array} attributes - JSX attributes array
|
|
191
|
+
* @return {boolean} Whether variant is "link"
|
|
192
|
+
*/
|
|
193
|
+
function hasLinkVariant( attributes ) {
|
|
194
|
+
const variantAttr = attributes.find(
|
|
195
|
+
( a ) =>
|
|
196
|
+
a.type === 'JSXAttribute' &&
|
|
197
|
+
a.name &&
|
|
198
|
+
a.name.name === 'variant'
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
if ( ! variantAttr ) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// String value like `variant="link"`
|
|
206
|
+
if (
|
|
207
|
+
variantAttr.value &&
|
|
208
|
+
variantAttr.value.type === 'Literal' &&
|
|
209
|
+
variantAttr.value.value === 'link'
|
|
210
|
+
) {
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
ImportDeclaration( node ) {
|
|
219
|
+
const source = node.source.value;
|
|
220
|
+
|
|
221
|
+
if ( ! shouldTrackImportSource( source ) ) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Handle named imports
|
|
226
|
+
node.specifiers.forEach( ( specifier ) => {
|
|
227
|
+
if ( specifier.type !== 'ImportSpecifier' ) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const importedName = specifier.imported.name;
|
|
232
|
+
const localName = specifier.local.name;
|
|
233
|
+
|
|
234
|
+
// Track components that require the prop
|
|
235
|
+
if (
|
|
236
|
+
COMPONENTS_REQUIRING_40PX.has( importedName ) ||
|
|
237
|
+
COMPONENTS_WITH_RENDER_EXEMPTION.has( importedName )
|
|
238
|
+
) {
|
|
239
|
+
trackedImports.set( localName, importedName );
|
|
240
|
+
}
|
|
241
|
+
} );
|
|
242
|
+
|
|
243
|
+
// Handle default imports when checking local imports
|
|
244
|
+
// e.g., import InputControl from '../input-control'
|
|
245
|
+
if ( checkLocalImports ) {
|
|
246
|
+
node.specifiers.forEach( ( specifier ) => {
|
|
247
|
+
if ( specifier.type === 'ImportDefaultSpecifier' ) {
|
|
248
|
+
const localName = specifier.local.name;
|
|
249
|
+
const inferredName =
|
|
250
|
+
inferComponentNameFromPath( source );
|
|
251
|
+
if ( inferredName ) {
|
|
252
|
+
trackedImports.set( localName, inferredName );
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Support patterns like `import ClipboardButton from '.';`
|
|
257
|
+
// (common in component folder examples/tests).
|
|
258
|
+
// If the local name matches a tracked component, treat it as such.
|
|
259
|
+
if ( ALL_TRACKED_COMPONENTS.has( localName ) ) {
|
|
260
|
+
trackedImports.set( localName, localName );
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} );
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
JSXOpeningElement( node ) {
|
|
268
|
+
// Only check simple JSX element names (not member expressions)
|
|
269
|
+
if ( node.name.type !== 'JSXIdentifier' ) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const elementName = node.name.name;
|
|
274
|
+
const importedName = trackedImports.get( elementName );
|
|
275
|
+
|
|
276
|
+
// Only check if this is a tracked component from @wordpress/components
|
|
277
|
+
if ( ! importedName ) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const attributes = node.attributes;
|
|
282
|
+
|
|
283
|
+
// Check if __next40pxDefaultSize has a truthy value
|
|
284
|
+
if (
|
|
285
|
+
hasTruthyJsxAttribute( attributes, '__next40pxDefaultSize' )
|
|
286
|
+
) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Handle FormFileUpload special case
|
|
291
|
+
if ( COMPONENTS_WITH_RENDER_EXEMPTION.has( importedName ) ) {
|
|
292
|
+
// FormFileUpload is valid if it has a `render` prop
|
|
293
|
+
if ( hasRenderProp( attributes ) ) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
context.report( {
|
|
298
|
+
node,
|
|
299
|
+
messageId: 'missingPropFormFileUpload',
|
|
300
|
+
} );
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// For other components, check if size prop has a non-default value
|
|
305
|
+
if ( hasNonDefaultSize( attributes ) ) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Button with variant="link" doesn't need __next40pxDefaultSize
|
|
310
|
+
if (
|
|
311
|
+
importedName === 'Button' &&
|
|
312
|
+
hasLinkVariant( attributes )
|
|
313
|
+
) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
context.report( {
|
|
318
|
+
node,
|
|
319
|
+
messageId: 'missingProp',
|
|
320
|
+
data: {
|
|
321
|
+
component: importedName,
|
|
322
|
+
},
|
|
323
|
+
} );
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
},
|
|
327
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
const { hasTruthyJsxAttribute } = require( '../utils' );
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Enforces that Button from @wordpress/components includes `accessibleWhenDisabled`
|
|
5
|
+
* when `disabled` is set.
|
|
6
|
+
*
|
|
7
|
+
* @type {import('eslint').Rule.RuleModule}
|
|
8
|
+
*/
|
|
9
|
+
module.exports = {
|
|
10
|
+
meta: {
|
|
11
|
+
type: 'problem',
|
|
12
|
+
schema: [
|
|
13
|
+
{
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
checkLocalImports: {
|
|
17
|
+
type: 'boolean',
|
|
18
|
+
description:
|
|
19
|
+
'When true, also checks components imported from relative paths (for use inside @wordpress/components package).',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
messages: {
|
|
26
|
+
missingAccessibleWhenDisabled:
|
|
27
|
+
'`disabled` used without the `accessibleWhenDisabled` prop. Disabling a control without maintaining focusability can cause accessibility issues, by hiding their presence from screen reader users, or preventing focus from returning to a trigger element. (Ignore this error if you truly mean to disable.)',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
create( context ) {
|
|
31
|
+
const checkLocalImports =
|
|
32
|
+
context.options[ 0 ]?.checkLocalImports ?? false;
|
|
33
|
+
|
|
34
|
+
// Track local names of Button imported from @wordpress/components
|
|
35
|
+
const wpComponentsButtons = new Set();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if the import source should be tracked.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} source - The import source path
|
|
41
|
+
* @return {boolean} Whether to track imports from this source
|
|
42
|
+
*/
|
|
43
|
+
function shouldTrackImportSource( source ) {
|
|
44
|
+
if ( source === '@wordpress/components' ) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// When checkLocalImports is enabled, also track relative imports
|
|
49
|
+
if ( checkLocalImports ) {
|
|
50
|
+
return source.startsWith( '.' ) || source.startsWith( '/' );
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
ImportDeclaration( node ) {
|
|
58
|
+
if ( ! shouldTrackImportSource( node.source.value ) ) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
node.specifiers.forEach( ( specifier ) => {
|
|
63
|
+
if ( specifier.type !== 'ImportSpecifier' ) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const importedName = specifier.imported.name;
|
|
68
|
+
if ( importedName === 'Button' ) {
|
|
69
|
+
// Track the local name (handles aliased imports)
|
|
70
|
+
wpComponentsButtons.add( specifier.local.name );
|
|
71
|
+
}
|
|
72
|
+
} );
|
|
73
|
+
|
|
74
|
+
// Also handle default imports when checking local imports
|
|
75
|
+
// e.g., import Button from './button'
|
|
76
|
+
if ( checkLocalImports ) {
|
|
77
|
+
node.specifiers.forEach( ( specifier ) => {
|
|
78
|
+
if ( specifier.type === 'ImportDefaultSpecifier' ) {
|
|
79
|
+
const localName = specifier.local.name;
|
|
80
|
+
// Check if the import path suggests it's a Button component
|
|
81
|
+
const source = node.source.value;
|
|
82
|
+
if (
|
|
83
|
+
source.endsWith( '/button' ) ||
|
|
84
|
+
source.endsWith( '/Button' )
|
|
85
|
+
) {
|
|
86
|
+
wpComponentsButtons.add( localName );
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} );
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
JSXOpeningElement( node ) {
|
|
94
|
+
// Only check simple JSX element names (not member expressions)
|
|
95
|
+
if ( node.name.type !== 'JSXIdentifier' ) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const elementName = node.name.name;
|
|
100
|
+
|
|
101
|
+
// Only check if this is a Button from @wordpress/components
|
|
102
|
+
if ( ! wpComponentsButtons.has( elementName ) ) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if ( ! hasTruthyJsxAttribute( node.attributes, 'disabled' ) ) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const hasAccessibleWhenDisabled = node.attributes.some(
|
|
111
|
+
( attr ) =>
|
|
112
|
+
attr.type === 'JSXAttribute' &&
|
|
113
|
+
attr.name &&
|
|
114
|
+
attr.name.name === 'accessibleWhenDisabled'
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if ( ! hasAccessibleWhenDisabled ) {
|
|
118
|
+
context.report( {
|
|
119
|
+
node,
|
|
120
|
+
messageId: 'missingAccessibleWhenDisabled',
|
|
121
|
+
} );
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( {
|
|
2
|
+
meta: {
|
|
3
|
+
type: 'problem',
|
|
4
|
+
docs: {
|
|
5
|
+
description:
|
|
6
|
+
'Disallow setting any CSS custom property beginning with --wpds- in inline styles',
|
|
7
|
+
},
|
|
8
|
+
schema: [],
|
|
9
|
+
messages: {
|
|
10
|
+
disallowedSet:
|
|
11
|
+
'Do not set CSS custom properties using the Design System tokens namespace (i.e. beginning with --wpds-*).',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
create( context ) {
|
|
15
|
+
return {
|
|
16
|
+
/** @param {import('estree').Property} node */
|
|
17
|
+
'JSXAttribute[name.name="style"] ObjectExpression > Property[key.value=/^--wpds-/]'(
|
|
18
|
+
node
|
|
19
|
+
) {
|
|
20
|
+
context.report( {
|
|
21
|
+
node: node.key,
|
|
22
|
+
messageId: 'disallowedSet',
|
|
23
|
+
} );
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
} );
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const tokenListModule = require( '@wordpress/theme/design-tokens.js' );
|
|
2
|
+
const tokenList = tokenListModule.default || tokenListModule;
|
|
3
|
+
|
|
4
|
+
const DS_TOKEN_PREFIX = 'wpds-';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extracts all unique CSS custom properties (variables) from a given CSS value string,
|
|
8
|
+
* including those in fallback positions, optionally filtering by a specific prefix.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} value - The CSS value string to search for variables.
|
|
11
|
+
* @param {string} [prefix=''] - Optional prefix to filter variables (e.g., 'wpds-').
|
|
12
|
+
* @return {Set<string>} A Set of unique matched CSS variable names (e.g., Set { '--wpds-token' }).
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* extractCSSVariables(
|
|
16
|
+
* 'border: 1px solid var(--wpds-border-color, var(--wpds-border-fallback)); ' +
|
|
17
|
+
* 'color: var(--wpds-color-fg, black); ' +
|
|
18
|
+
* 'background: var(--unrelated-bg);',
|
|
19
|
+
* 'wpds'
|
|
20
|
+
* );
|
|
21
|
+
* // → Set { '--wpds-border-color', '--wpds-border-fallback', '--wpds-color-fg' }
|
|
22
|
+
*/
|
|
23
|
+
function extractCSSVariables( value, prefix = '' ) {
|
|
24
|
+
const regex = /--[\w-]+/g;
|
|
25
|
+
const variables = new Set();
|
|
26
|
+
|
|
27
|
+
let match;
|
|
28
|
+
while ( ( match = regex.exec( value ) ) !== null ) {
|
|
29
|
+
const variableName = match[ 0 ];
|
|
30
|
+
if ( variableName.startsWith( `--${ prefix }` ) ) {
|
|
31
|
+
variables.add( variableName );
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return variables;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const knownTokens = new Set( tokenList );
|
|
39
|
+
const wpdsTokensRegex = new RegExp( `[^\\w]--${ DS_TOKEN_PREFIX }`, 'i' );
|
|
40
|
+
|
|
41
|
+
module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( {
|
|
42
|
+
meta: {
|
|
43
|
+
type: 'problem',
|
|
44
|
+
docs: {
|
|
45
|
+
description: 'Prevent use of non-existing --wpds-* variables',
|
|
46
|
+
},
|
|
47
|
+
schema: [],
|
|
48
|
+
messages: {
|
|
49
|
+
onlyKnownTokens:
|
|
50
|
+
'The following CSS variables are not valid Design System tokens: {{ tokenNames }}',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
create( context ) {
|
|
54
|
+
const disallowedTokensAST = `JSXAttribute[name.name="style"] :matches(Literal[value=${ wpdsTokensRegex }], TemplateLiteral TemplateElement[value.raw=${ wpdsTokensRegex }])`;
|
|
55
|
+
return {
|
|
56
|
+
/** @param {import('estree').Literal | import('estree').TemplateElement} node */
|
|
57
|
+
[ disallowedTokensAST ]( node ) {
|
|
58
|
+
let computedValue;
|
|
59
|
+
|
|
60
|
+
if ( ! node.value ) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if ( typeof node.value === 'string' ) {
|
|
65
|
+
// Get the node's value when it's a "string"
|
|
66
|
+
computedValue = node.value;
|
|
67
|
+
} else if (
|
|
68
|
+
typeof node.value === 'object' &&
|
|
69
|
+
'raw' in node.value
|
|
70
|
+
) {
|
|
71
|
+
// Get the node's value when it's a `template literal`
|
|
72
|
+
computedValue = node.value.cooked ?? node.value.raw;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if ( ! computedValue ) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const usedTokens = extractCSSVariables(
|
|
80
|
+
computedValue,
|
|
81
|
+
DS_TOKEN_PREFIX
|
|
82
|
+
);
|
|
83
|
+
const unknownTokens = [ ...usedTokens ].filter(
|
|
84
|
+
( token ) => ! knownTokens.has( token )
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if ( unknownTokens.length > 0 ) {
|
|
88
|
+
context.report( {
|
|
89
|
+
node,
|
|
90
|
+
messageId: 'onlyKnownTokens',
|
|
91
|
+
data: {
|
|
92
|
+
tokenNames: unknownTokens
|
|
93
|
+
.map( ( token ) => `'${ token }'` )
|
|
94
|
+
.join( ', ' ),
|
|
95
|
+
},
|
|
96
|
+
} );
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
} );
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a JSX/React attribute exists and has a truthy value.
|
|
3
|
+
*
|
|
4
|
+
* This utility analyzes JSX attribute nodes from an ESLint AST to determine
|
|
5
|
+
* if a specific attribute is present with a truthy value.
|
|
6
|
+
*
|
|
7
|
+
* Handles the following patterns:
|
|
8
|
+
* - Boolean shorthand: `<Component prop />` → truthy
|
|
9
|
+
* - Explicit true: `<Component prop={true} />` → truthy
|
|
10
|
+
* - Explicit false: `<Component prop={false} />` → NOT truthy
|
|
11
|
+
* - String values: `<Component prop="value" />` → truthy (if non-empty)
|
|
12
|
+
* - Dynamic expressions: `<Component prop={someVar} />` → assumed truthy
|
|
13
|
+
*
|
|
14
|
+
* @param {Array} attributes - Array of JSX attribute nodes from the ESLint AST
|
|
15
|
+
* @param {string} attrName - The attribute name to check
|
|
16
|
+
* @return {boolean} Whether the attribute exists with a truthy value
|
|
17
|
+
*/
|
|
18
|
+
function hasTruthyJsxAttribute( attributes, attrName ) {
|
|
19
|
+
const attr = attributes.find(
|
|
20
|
+
( a ) => a.type === 'JSXAttribute' && a.name && a.name.name === attrName
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
if ( ! attr ) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Boolean attribute without value (e.g., `<Button disabled />`)
|
|
28
|
+
if ( attr.value === null ) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Expression like `prop={true}` or `prop={false}`
|
|
33
|
+
if (
|
|
34
|
+
attr.value.type === 'JSXExpressionContainer' &&
|
|
35
|
+
attr.value.expression.type === 'Literal'
|
|
36
|
+
) {
|
|
37
|
+
return attr.value.expression.value !== false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// String value - truthy if not empty
|
|
41
|
+
if ( attr.value.type === 'Literal' ) {
|
|
42
|
+
return Boolean( attr.value.value );
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// For any other expression (variables, function calls, etc.),
|
|
46
|
+
// assume it could be truthy since we can't statically analyze it
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { hasTruthyJsxAttribute };
|
package/utils/index.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
const { getTranslateFunctionArgs } = require( './get-translate-function-args' );
|
|
10
10
|
const { getTextContentFromNode } = require( './get-text-content-from-node' );
|
|
11
11
|
const { getTranslateFunctionName } = require( './get-translate-function-name' );
|
|
12
|
+
const { hasTruthyJsxAttribute } = require( './has-truthy-jsx-attribute' );
|
|
12
13
|
const isPackageInstalled = require( './is-package-installed' );
|
|
13
14
|
|
|
14
15
|
module.exports = {
|
|
@@ -18,5 +19,6 @@ module.exports = {
|
|
|
18
19
|
getTranslateFunctionArgs,
|
|
19
20
|
getTextContentFromNode,
|
|
20
21
|
getTranslateFunctionName,
|
|
22
|
+
hasTruthyJsxAttribute,
|
|
21
23
|
isPackageInstalled,
|
|
22
24
|
};
|