@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 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/*` packagesl | ✓ |
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": "23.0.1-next.76cff8c98.0",
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.36.1-next.76cff8c98.0",
44
- "@wordpress/prettier-config": "^4.36.1-next.76cff8c98.0",
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": "368727f14b858e75179e140967c2d9ec965c8790"
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
  };