@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.
@@ -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: [ 'Badge', 'Stack', 'Text' ],
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
+ };