@wordpress/eslint-plugin 24.0.1-next.ba3aee3a2.0 → 24.1.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.
@@ -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
+ };
@@ -1,5 +1,9 @@
1
1
  /** @typedef {import('estree').Comment} Comment */
2
2
  /** @typedef {import('estree').Node} Node */
3
+ /** @typedef {import('estree').SourceLocation} SourceLocation */
4
+
5
+ const DEPENDENCY_BLOCK_PATTERN =
6
+ /^\*?\n \* (External|Node|WordPress|Internal) dependencies\n $/;
3
7
 
4
8
  /** @type {import('eslint').Rule.RuleModule} */
5
9
  module.exports = {
@@ -9,10 +13,15 @@ module.exports = {
9
13
  description: 'Enforce dependencies docblocks formatting',
10
14
  url: 'https://github.com/WordPress/gutenberg/blob/HEAD/packages/eslint-plugin/docs/rules/dependency-group.md',
11
15
  },
12
- schema: [],
16
+ schema: [
17
+ {
18
+ enum: [ 'always', 'never' ],
19
+ },
20
+ ],
13
21
  fixable: 'code',
14
22
  },
15
23
  create( context ) {
24
+ const mode = context.options[ 0 ] || 'always';
16
25
  const comments = context.getSourceCode().getAllComments();
17
26
 
18
27
  /**
@@ -94,6 +103,20 @@ module.exports = {
94
103
  return pattern.test( value );
95
104
  }
96
105
 
106
+ /**
107
+ * Returns true if the given comment node is any dependency block comment.
108
+ *
109
+ * @param {Comment} node Comment node to check.
110
+ *
111
+ * @return {boolean} Whether comment node is a dependency block.
112
+ */
113
+ function isDependencyBlock( node ) {
114
+ return (
115
+ node.type === 'Block' &&
116
+ DEPENDENCY_BLOCK_PATTERN.test( node.value )
117
+ );
118
+ }
119
+
97
120
  /**
98
121
  * Returns true if the given node occurs prior in code to a reference,
99
122
  * or false otherwise.
@@ -157,6 +180,46 @@ module.exports = {
157
180
  * @param {import('estree').Program} node Program node.
158
181
  */
159
182
  Program( node ) {
183
+ if ( mode === 'never' ) {
184
+ for ( const comment of comments ) {
185
+ if ( isDependencyBlock( comment ) ) {
186
+ context.report( {
187
+ loc: /** @type {SourceLocation} */ (
188
+ comment.loc
189
+ ),
190
+ message:
191
+ 'Unexpected dependency group comment block',
192
+ fix( fixer ) {
193
+ if ( ! comment.range ) {
194
+ return null;
195
+ }
196
+
197
+ const text = context
198
+ .getSourceCode()
199
+ .getText();
200
+
201
+ // Trim preceding and trailing newlines.
202
+ let [ start, end ] = comment.range;
203
+ while (
204
+ start > 1 &&
205
+ text[ start - 1 ] === '\n' &&
206
+ text[ start - 2 ] === '\n'
207
+ ) {
208
+ start--;
209
+ }
210
+ while ( text[ end ] === '\n' ) {
211
+ end++;
212
+ }
213
+
214
+ return fixer.removeRange( [ start, end ] );
215
+ },
216
+ } );
217
+ }
218
+ }
219
+
220
+ return;
221
+ }
222
+
160
223
  /**
161
224
  * The set of package localities which have been reported for
162
225
  * the current program. Each locality is reported at most one
@@ -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
+ } );