@wordpress/eslint-plugin 25.0.0 → 25.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.
- package/README.md +18 -4
- package/configs/babel-parser-compat.js +1 -3
- package/configs/custom.js +1 -0
- package/package.json +5 -5
- package/rules/__tests__/no-setting-ds-tokens.js +52 -0
- package/rules/__tests__/no-unknown-ds-tokens.js +54 -0
- package/rules/__tests__/no-unsafe-render-order.js +136 -0
- package/rules/__tests__/use-import-as.js +271 -0
- package/rules/__tests__/use-recommended-components.js +3 -1
- package/rules/no-ds-tokens.js +1 -3
- package/rules/no-setting-ds-tokens.js +57 -0
- package/rules/no-unknown-ds-tokens.js +86 -120
- package/rules/no-unsafe-render-order.js +158 -0
- package/rules/no-unsafe-wp-apis.js +1 -1
- package/rules/use-import-as.js +310 -0
- package/rules/use-recommended-components.js +25 -1
- package/utils/ds-token-utils.js +58 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
2
|
+
const rule = {
|
|
3
|
+
meta: {
|
|
4
|
+
type: 'suggestion',
|
|
5
|
+
hasSuggestions: true,
|
|
6
|
+
docs: {
|
|
7
|
+
description:
|
|
8
|
+
'Enforce configured `as` names for specific named imports and unlocked private APIs.',
|
|
9
|
+
url: 'https://github.com/WordPress/gutenberg/blob/HEAD/packages/eslint-plugin/docs/rules/use-import-as.md',
|
|
10
|
+
},
|
|
11
|
+
schema: [
|
|
12
|
+
{
|
|
13
|
+
type: 'object',
|
|
14
|
+
additionalProperties: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
minProperties: 1,
|
|
17
|
+
additionalProperties: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
messages: {
|
|
24
|
+
mustUseImportAs:
|
|
25
|
+
'`{{ importedName }}` from `{{ source }}` must be imported as `{{ localName }}`.',
|
|
26
|
+
useImportAsSuggestion: 'Import as `{{ localName }}`.',
|
|
27
|
+
useUnlockAsSuggestion: 'Destructure as `{{ localName }}`.',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
create( context ) {
|
|
31
|
+
const importAsMap =
|
|
32
|
+
context.options.length > 0 &&
|
|
33
|
+
context.options[ 0 ] &&
|
|
34
|
+
typeof context.options[ 0 ] === 'object'
|
|
35
|
+
? context.options[ 0 ]
|
|
36
|
+
: {};
|
|
37
|
+
const privateApisSources = new Map();
|
|
38
|
+
const trackedUnlockImports = new Set();
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
/** @param {import('estree').ImportDeclaration} node */
|
|
42
|
+
ImportDeclaration( node ) {
|
|
43
|
+
if ( typeof node.source.value !== 'string' ) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const source = node.source.value;
|
|
48
|
+
const sourceMap = importAsMap[ source ];
|
|
49
|
+
|
|
50
|
+
node.specifiers.forEach( ( specifier ) => {
|
|
51
|
+
if ( specifier.type !== 'ImportSpecifier' ) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const importedName = getImportedName( specifier );
|
|
56
|
+
if ( importedName === 'unlock' ) {
|
|
57
|
+
trackedUnlockImports.add( specifier.local.name );
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if ( ! sourceMap ) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if ( importedName === 'privateApis' ) {
|
|
65
|
+
privateApisSources.set( specifier.local.name, source );
|
|
66
|
+
}
|
|
67
|
+
const localName = sourceMap[ importedName ];
|
|
68
|
+
|
|
69
|
+
if ( ! localName || specifier.local.name === localName ) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
context.report( {
|
|
74
|
+
node: specifier.local,
|
|
75
|
+
messageId: 'mustUseImportAs',
|
|
76
|
+
data: {
|
|
77
|
+
importedName,
|
|
78
|
+
source,
|
|
79
|
+
localName,
|
|
80
|
+
},
|
|
81
|
+
suggest: [
|
|
82
|
+
{
|
|
83
|
+
messageId: 'useImportAsSuggestion',
|
|
84
|
+
data: {
|
|
85
|
+
localName,
|
|
86
|
+
},
|
|
87
|
+
fix( fixer ) {
|
|
88
|
+
return fixer.replaceText(
|
|
89
|
+
specifier,
|
|
90
|
+
getImportSpecifierSuggestionText(
|
|
91
|
+
specifier,
|
|
92
|
+
context.sourceCode,
|
|
93
|
+
localName
|
|
94
|
+
)
|
|
95
|
+
);
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
} );
|
|
100
|
+
} );
|
|
101
|
+
},
|
|
102
|
+
/** @param {import('estree').VariableDeclarator} node */
|
|
103
|
+
VariableDeclarator( node ) {
|
|
104
|
+
if (
|
|
105
|
+
node.parent.type !== 'VariableDeclaration' ||
|
|
106
|
+
node.parent.kind !== 'const' ||
|
|
107
|
+
node.id.type !== 'ObjectPattern' ||
|
|
108
|
+
! isUnlockCall(
|
|
109
|
+
node.init,
|
|
110
|
+
context.sourceCode,
|
|
111
|
+
trackedUnlockImports
|
|
112
|
+
)
|
|
113
|
+
) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const privateApisIdentifier = node.init.arguments[ 0 ];
|
|
118
|
+
if ( privateApisIdentifier.type !== 'Identifier' ) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const source = privateApisSources.get(
|
|
123
|
+
privateApisIdentifier.name
|
|
124
|
+
);
|
|
125
|
+
if ( ! source ) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const sourceMap = importAsMap[ source ];
|
|
130
|
+
if ( ! sourceMap ) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
node.id.properties.forEach( ( property ) => {
|
|
135
|
+
if ( property.type !== 'Property' || property.computed ) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const importedName = getPropertyName( property.key );
|
|
140
|
+
if ( ! importedName ) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const localName = sourceMap[ importedName ];
|
|
145
|
+
if ( ! localName ) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const propertyLocalName = getPropertyLocalName(
|
|
150
|
+
property.value
|
|
151
|
+
);
|
|
152
|
+
if (
|
|
153
|
+
! propertyLocalName ||
|
|
154
|
+
propertyLocalName === localName
|
|
155
|
+
) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
context.report( {
|
|
160
|
+
node: getReportNode( property.value ),
|
|
161
|
+
messageId: 'mustUseImportAs',
|
|
162
|
+
data: {
|
|
163
|
+
importedName,
|
|
164
|
+
source,
|
|
165
|
+
localName,
|
|
166
|
+
},
|
|
167
|
+
suggest: [
|
|
168
|
+
{
|
|
169
|
+
messageId: 'useUnlockAsSuggestion',
|
|
170
|
+
data: {
|
|
171
|
+
localName,
|
|
172
|
+
},
|
|
173
|
+
fix( fixer ) {
|
|
174
|
+
return fixer.replaceText(
|
|
175
|
+
property,
|
|
176
|
+
getPropertySuggestionText(
|
|
177
|
+
property,
|
|
178
|
+
context.sourceCode,
|
|
179
|
+
localName
|
|
180
|
+
)
|
|
181
|
+
);
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
} );
|
|
186
|
+
} );
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {import('estree').ImportSpecifier} specifier
|
|
194
|
+
* @return {string} Imported name.
|
|
195
|
+
*/
|
|
196
|
+
function getImportedName( specifier ) {
|
|
197
|
+
return specifier.imported.type === 'Identifier'
|
|
198
|
+
? specifier.imported.name
|
|
199
|
+
: String( specifier.imported.value );
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {import('estree').ImportSpecifier} specifier
|
|
204
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
205
|
+
* @param {string} localName
|
|
206
|
+
* @return {string} Suggested replacement text for an import specifier.
|
|
207
|
+
*/
|
|
208
|
+
function getImportSpecifierSuggestionText( specifier, sourceCode, localName ) {
|
|
209
|
+
return `${ sourceCode.getText( specifier.imported ) } as ${ localName }`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @param {import('estree').CallExpression|import('estree').Expression|null} node
|
|
214
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
215
|
+
* @param {ReadonlySet<string>} trackedUnlockImports
|
|
216
|
+
* @return {node is import('estree').CallExpression} Whether this is an `unlock()` call with one argument.
|
|
217
|
+
*/
|
|
218
|
+
function isUnlockCall( node, sourceCode, trackedUnlockImports ) {
|
|
219
|
+
if (
|
|
220
|
+
node &&
|
|
221
|
+
node.type === 'CallExpression' &&
|
|
222
|
+
node.callee.type === 'Identifier' &&
|
|
223
|
+
node.arguments.length === 1
|
|
224
|
+
) {
|
|
225
|
+
if ( ! trackedUnlockImports.has( node.callee.name ) ) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const { references } = sourceCode.getScope( node.callee );
|
|
230
|
+
const reference = references.find(
|
|
231
|
+
( currentReference ) => currentReference.identifier === node.callee
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return !! reference?.resolved?.defs.some(
|
|
235
|
+
( definition ) => definition.type === 'ImportBinding'
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {import('estree').Expression|import('estree').PrivateIdentifier} key
|
|
244
|
+
* @return {string|null} Property name.
|
|
245
|
+
*/
|
|
246
|
+
function getPropertyName( key ) {
|
|
247
|
+
if ( key.type === 'Identifier' ) {
|
|
248
|
+
return key.name;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if ( key.type === 'Literal' ) {
|
|
252
|
+
return String( key.value );
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {import('estree').Property} property
|
|
260
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
261
|
+
* @param {string} localName
|
|
262
|
+
* @return {string} Suggested replacement text for a destructuring property.
|
|
263
|
+
*/
|
|
264
|
+
function getPropertySuggestionText( property, sourceCode, localName ) {
|
|
265
|
+
const keyText = sourceCode.getText( property.key );
|
|
266
|
+
|
|
267
|
+
if ( property.value.type === 'AssignmentPattern' ) {
|
|
268
|
+
return `${ keyText }: ${ localName } = ${ sourceCode.getText(
|
|
269
|
+
property.value.right
|
|
270
|
+
) }`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return `${ keyText }: ${ localName }`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @param {import('estree').Pattern} value
|
|
278
|
+
* @return {string|null} Local variable name.
|
|
279
|
+
*/
|
|
280
|
+
function getPropertyLocalName( value ) {
|
|
281
|
+
if ( value.type === 'Identifier' ) {
|
|
282
|
+
return value.name;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (
|
|
286
|
+
value.type === 'AssignmentPattern' &&
|
|
287
|
+
value.left.type === 'Identifier'
|
|
288
|
+
) {
|
|
289
|
+
return value.left.name;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @param {import('estree').Pattern} value
|
|
297
|
+
* @return {import('estree').Node} Node to report on.
|
|
298
|
+
*/
|
|
299
|
+
function getReportNode( value ) {
|
|
300
|
+
if (
|
|
301
|
+
value.type === 'AssignmentPattern' &&
|
|
302
|
+
value.left.type === 'Identifier'
|
|
303
|
+
) {
|
|
304
|
+
return value.left;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
module.exports = rule;
|
|
@@ -8,7 +8,17 @@
|
|
|
8
8
|
*/
|
|
9
9
|
const ALLOWLIST = {
|
|
10
10
|
'@wordpress/ui': {
|
|
11
|
-
allowed: [
|
|
11
|
+
allowed: [
|
|
12
|
+
'Badge',
|
|
13
|
+
'Card',
|
|
14
|
+
'Collapsible',
|
|
15
|
+
'CollapsibleCard',
|
|
16
|
+
'EmptyState',
|
|
17
|
+
'Link',
|
|
18
|
+
'Stack',
|
|
19
|
+
'Text',
|
|
20
|
+
'VisuallyHidden',
|
|
21
|
+
],
|
|
12
22
|
message:
|
|
13
23
|
'`{{ name }}` from `{{ source }}` is not yet recommended for use in a WordPress environment.',
|
|
14
24
|
},
|
|
@@ -24,8 +34,22 @@ const ALLOWLIST = {
|
|
|
24
34
|
*/
|
|
25
35
|
const DENYLIST = {
|
|
26
36
|
'@wordpress/components': {
|
|
37
|
+
ExternalLink:
|
|
38
|
+
'Use `Link` from `@wordpress/ui` with the `openInNewTab` prop instead.',
|
|
39
|
+
__experimentalHeading: 'Use `Text` from `@wordpress/ui` instead.',
|
|
40
|
+
__experimentalHStack: 'Use `Stack` from `@wordpress/ui` instead.',
|
|
41
|
+
__experimentalText: 'Use `Text` from `@wordpress/ui` instead.',
|
|
42
|
+
__experimentalVStack: 'Use `Stack` from `@wordpress/ui` instead.',
|
|
27
43
|
__experimentalZStack:
|
|
28
44
|
'{{ name }} is planned for deprecation. Write your own CSS instead.',
|
|
45
|
+
Card: 'Use `Card.Root` from `@wordpress/ui` instead.',
|
|
46
|
+
CardBody: 'Use `Card.Content` from `@wordpress/ui` instead.',
|
|
47
|
+
CardDivider: 'A divider is no longer a standard pattern for cards.',
|
|
48
|
+
CardFooter: 'A footer is no longer a standard pattern for cards.',
|
|
49
|
+
CardHeader:
|
|
50
|
+
'Use `Card.Header` (and optionally `Card.Title`) from `@wordpress/ui` instead.',
|
|
51
|
+
CardMedia: 'Use `Card.FullBleed` from `@wordpress/ui` instead.',
|
|
52
|
+
VisuallyHidden: 'Use `{{ name }}` from `@wordpress/ui` instead.',
|
|
29
53
|
},
|
|
30
54
|
};
|
|
31
55
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const DS_TOKEN_PREFIX = 'wpds-';
|
|
2
|
+
const wpdsTokensRegex = new RegExp( `(?:^|[^\\w])--${ DS_TOKEN_PREFIX }`, 'i' );
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Single-pass extraction that finds all `--prefix-*` tokens in a CSS value
|
|
6
|
+
* string, classifying each occurrence as `var()`-wrapped or bare, and whether
|
|
7
|
+
* it appears in a declaration position (i.e. followed by `:`).
|
|
8
|
+
*
|
|
9
|
+
* @param {string} value - The CSS value string to search.
|
|
10
|
+
* @param {string} [prefix=''] - Optional prefix to filter variables (e.g., 'wpds-').
|
|
11
|
+
*/
|
|
12
|
+
function collectTokenOccurrences( value, prefix = '' ) {
|
|
13
|
+
const regex = new RegExp(
|
|
14
|
+
`(?:^|[^\\w])(var\\(\\s*)?(--${ prefix }[\\w-]+)`,
|
|
15
|
+
'g'
|
|
16
|
+
);
|
|
17
|
+
const occurrences = [];
|
|
18
|
+
|
|
19
|
+
let match;
|
|
20
|
+
while ( ( match = regex.exec( value ) ) !== null ) {
|
|
21
|
+
occurrences.push( {
|
|
22
|
+
token: match[ 2 ],
|
|
23
|
+
bare: ! match[ 1 ],
|
|
24
|
+
declaration: /^\s*:/.test( value.slice( regex.lastIndex ) ),
|
|
25
|
+
} );
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return occurrences;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns the static string value for a literal-like node, or `undefined`
|
|
33
|
+
* when the node does not contain one.
|
|
34
|
+
*
|
|
35
|
+
* @param {import('estree').Literal | import('estree').TemplateElement} node
|
|
36
|
+
* @return {string | undefined} The static string value, or `undefined`
|
|
37
|
+
* when the node does not contain one.
|
|
38
|
+
*/
|
|
39
|
+
function getStaticNodeValue( node ) {
|
|
40
|
+
if ( ! node.value ) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if ( typeof node.value === 'string' ) {
|
|
45
|
+
return node.value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if ( typeof node.value === 'object' && 'raw' in node.value ) {
|
|
49
|
+
return node.value.cooked ?? node.value.raw;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
DS_TOKEN_PREFIX,
|
|
55
|
+
collectTokenOccurrences,
|
|
56
|
+
getStaticNodeValue,
|
|
57
|
+
wpdsTokensRegex,
|
|
58
|
+
};
|