@wordpress/eslint-plugin 25.0.1-next.v.202604201441.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.
@@ -1,3 +1,19 @@
1
+ const {
2
+ DS_TOKEN_PREFIX,
3
+ collectTokenOccurrences,
4
+ getStaticNodeValue,
5
+ wpdsTokensRegex,
6
+ } = require( '../utils/ds-token-utils' );
7
+
8
+ const wpdsDeclarationRegex = new RegExp(
9
+ `(?:^|[^\\w])--${ DS_TOKEN_PREFIX }[\\w-]+\\s*:`,
10
+ 'i'
11
+ );
12
+ const dynamicDeclarationStartRegex = new RegExp(
13
+ `--${ DS_TOKEN_PREFIX }[\\w-]*$`
14
+ );
15
+ const dynamicDeclarationEndRegex = /^[\w-]*\s*:/;
16
+
1
17
  module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( {
2
18
  meta: {
3
19
  type: 'problem',
@@ -12,6 +28,9 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( {
12
28
  },
13
29
  },
14
30
  create( context ) {
31
+ const staticDeclarationAST = `:matches(Literal[value=${ wpdsDeclarationRegex }], TemplateLiteral[expressions.length=0] TemplateElement[value.raw=${ wpdsDeclarationRegex }])`;
32
+ const dynamicTemplateLiteralAST = `TemplateLiteral[expressions.length>0]:has(TemplateElement[value.raw=${ wpdsTokensRegex }])`;
33
+
15
34
  return {
16
35
  /** @param {import('estree').Property} node */
17
36
  'ObjectExpression > Property[key.value=/^--wpds-/]'( node ) {
@@ -20,6 +39,44 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( {
20
39
  messageId: 'disallowedSet',
21
40
  } );
22
41
  },
42
+ /** @param {import('estree').Literal | import('estree').TemplateElement} node */
43
+ [ staticDeclarationAST ]( node ) {
44
+ context.report( {
45
+ node,
46
+ messageId: 'disallowedSet',
47
+ } );
48
+ },
49
+ /** @param {import('estree').TemplateLiteral} node */
50
+ [ dynamicTemplateLiteralAST ]( node ) {
51
+ for ( let index = 0; index < node.quasis.length; index++ ) {
52
+ const quasi = node.quasis[ index ];
53
+ const value = getStaticNodeValue( quasi );
54
+ const nextValue = node.quasis[ index + 1 ]
55
+ ? getStaticNodeValue( node.quasis[ index + 1 ] )
56
+ : undefined;
57
+
58
+ if ( ! value ) {
59
+ continue;
60
+ }
61
+ const hasStaticDeclaration = collectTokenOccurrences(
62
+ value,
63
+ DS_TOKEN_PREFIX
64
+ ).some( ( { declaration } ) => declaration );
65
+ const hasSplitDeclaration =
66
+ ! quasi.tail &&
67
+ dynamicDeclarationStartRegex.test( value ) &&
68
+ nextValue &&
69
+ dynamicDeclarationEndRegex.test( nextValue );
70
+
71
+ if ( hasStaticDeclaration || hasSplitDeclaration ) {
72
+ context.report( {
73
+ node,
74
+ messageId: 'disallowedSet',
75
+ } );
76
+ return;
77
+ }
78
+ }
79
+ },
23
80
  };
24
81
  },
25
82
  } );
@@ -1,48 +1,69 @@
1
1
  const tokenListModule = require( '@wordpress/theme/design-tokens.js' );
2
2
  const tokenList = tokenListModule.default || tokenListModule;
3
3
 
4
- const DS_TOKEN_PREFIX = 'wpds-';
4
+ const {
5
+ DS_TOKEN_PREFIX,
6
+ collectTokenOccurrences,
7
+ getStaticNodeValue,
8
+ wpdsTokensRegex,
9
+ } = require( '../utils/ds-token-utils' );
10
+
11
+ const knownTokens = new Set( tokenList );
5
12
 
6
13
  /**
7
- * Single-pass extraction that finds all `--prefix-*` tokens in a CSS value
8
- * string and classifies each occurrence as `var()`-wrapped or bare.
9
- *
10
- * @param {string} value - The CSS value string to search.
11
- * @param {string} [prefix=''] - Optional prefix to filter variables (e.g., 'wpds-').
12
- * @return {{ tokens: Set<string>, bare: Set<string> }}
13
- * `tokens` — every unique matched token;
14
- * `bare` — the subset that appeared at least once without a `var()` wrapper.
15
- *
16
- * @example
17
- * classifyTokens(
18
- * 'var(--wpds-color-fg) --wpds-color-bg',
19
- * 'wpds-'
20
- * );
21
- * // → { tokens: Set {'--wpds-color-fg','--wpds-color-bg'},
22
- * // bare: Set {'--wpds-color-bg'} }
14
+ * @param {Array<{ token: string, bare: boolean, declaration: boolean }>} occurrences
15
+ * @param {{ includeBareTokens?: boolean }} [options]
23
16
  */
24
- function classifyTokens( value, prefix = '' ) {
25
- const regex = new RegExp(
26
- `(?:^|[^\\w])(var\\(\\s*)?(--${ prefix }[\\w-]+)`,
27
- 'g'
28
- );
29
- const tokens = new Set();
30
- const bare = new Set();
31
-
32
- let match;
33
- while ( ( match = regex.exec( value ) ) !== null ) {
34
- const token = match[ 2 ];
35
- tokens.add( token );
36
- if ( ! match[ 1 ] ) {
37
- bare.add( token );
17
+ function getInvalidTokenNames(
18
+ occurrences,
19
+ { includeBareTokens = true } = {}
20
+ ) {
21
+ const unknownTokens = new Set();
22
+ const bareTokens = new Set();
23
+
24
+ for ( const { token, bare, declaration } of occurrences ) {
25
+ if ( ! knownTokens.has( token ) ) {
26
+ unknownTokens.add( token );
27
+ continue;
28
+ }
29
+
30
+ if ( includeBareTokens && bare && ! declaration ) {
31
+ bareTokens.add( token );
38
32
  }
39
33
  }
40
34
 
41
- return { tokens, bare };
35
+ return {
36
+ unknownTokens: [ ...unknownTokens ],
37
+ bareTokens: [ ...bareTokens ],
38
+ };
42
39
  }
43
40
 
44
- const knownTokens = new Set( tokenList );
45
- const wpdsTokensRegex = new RegExp( `(?:^|[^\\w])--${ DS_TOKEN_PREFIX }`, 'i' );
41
+ /**
42
+ * @param {string[]} tokenNames
43
+ */
44
+ function formatTokenNames( tokenNames ) {
45
+ return tokenNames.map( ( token ) => `'${ token }'` ).join( ', ' );
46
+ }
47
+
48
+ /**
49
+ * @param {import('eslint').Rule.RuleContext} context
50
+ * @param {import('estree').Node} node
51
+ * @param {'onlyKnownTokens' | 'bareToken'} messageId
52
+ * @param {string[]} tokenNames
53
+ */
54
+ function reportTokenNames( context, node, messageId, tokenNames ) {
55
+ if ( tokenNames.length === 0 ) {
56
+ return;
57
+ }
58
+
59
+ context.report( {
60
+ node,
61
+ messageId,
62
+ data: {
63
+ tokenNames: formatTokenNames( tokenNames ),
64
+ },
65
+ } );
66
+ }
46
67
 
47
68
  module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( {
48
69
  meta: {
@@ -78,8 +99,7 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( {
78
99
  */
79
100
  [ dynamicTemplateLiteralAST ]( node ) {
80
101
  let hasDynamic = false;
81
- const unknownTokens = [];
82
- const bareTokens = [];
102
+ const occurrences = [];
83
103
 
84
104
  for ( const quasi of node.quasis ) {
85
105
  const raw = quasi.value.raw;
@@ -93,30 +113,26 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( {
93
113
  hasDynamic = true;
94
114
  }
95
115
 
96
- const { tokens, bare } = classifyTokens(
116
+ let quasiOccurrences = collectTokenOccurrences(
97
117
  value,
98
118
  DS_TOKEN_PREFIX
99
119
  );
100
120
 
101
- // Remove the trailing incomplete token — it's the one
102
- // being dynamically constructed by the next expression.
103
121
  if ( isFollowedByExpression ) {
104
122
  const endMatch = value.match( /(--([\w-]+))$/ );
105
123
  if ( endMatch ) {
106
- tokens.delete( endMatch[ 1 ] );
107
- bare.delete( endMatch[ 1 ] );
124
+ quasiOccurrences = quasiOccurrences.filter(
125
+ ( { token } ) => token !== endMatch[ 1 ]
126
+ );
108
127
  }
109
128
  }
110
129
 
111
- for ( const token of tokens ) {
112
- if ( ! knownTokens.has( token ) ) {
113
- unknownTokens.push( token );
114
- } else if ( bare.has( token ) ) {
115
- bareTokens.push( token );
116
- }
117
- }
130
+ occurrences.push( ...quasiOccurrences );
118
131
  }
119
132
 
133
+ const { unknownTokens, bareTokens } =
134
+ getInvalidTokenNames( occurrences );
135
+
120
136
  if ( hasDynamic ) {
121
137
  context.report( {
122
138
  node,
@@ -124,95 +140,45 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( {
124
140
  } );
125
141
  }
126
142
 
127
- if ( unknownTokens.length > 0 ) {
128
- context.report( {
129
- node,
130
- messageId: 'onlyKnownTokens',
131
- data: {
132
- tokenNames: unknownTokens
133
- .map( ( token ) => `'${ token }'` )
134
- .join( ', ' ),
135
- },
136
- } );
137
- }
138
-
139
- if ( bareTokens.length > 0 ) {
140
- context.report( {
141
- node,
142
- messageId: 'bareToken',
143
- data: {
144
- tokenNames: bareTokens
145
- .map( ( token ) => `'${ token }'` )
146
- .join( ', ' ),
147
- },
148
- } );
149
- }
143
+ reportTokenNames(
144
+ context,
145
+ node,
146
+ 'onlyKnownTokens',
147
+ unknownTokens
148
+ );
149
+ reportTokenNames( context, node, 'bareToken', bareTokens );
150
150
  },
151
151
  /** @param {import('estree').Literal | import('estree').TemplateElement} node */
152
152
  [ staticTokensAST ]( node ) {
153
- let computedValue;
154
-
155
- if ( ! node.value ) {
156
- return;
157
- }
158
-
159
- if ( typeof node.value === 'string' ) {
160
- computedValue = node.value;
161
- } else if (
162
- typeof node.value === 'object' &&
163
- 'raw' in node.value
164
- ) {
165
- computedValue = node.value.cooked ?? node.value.raw;
166
- }
153
+ const computedValue = getStaticNodeValue( node );
167
154
 
168
155
  if ( ! computedValue ) {
169
156
  return;
170
157
  }
171
158
 
172
- const { tokens: usedTokens, bare } = classifyTokens(
159
+ const occurrences = collectTokenOccurrences(
173
160
  computedValue,
174
161
  DS_TOKEN_PREFIX
175
162
  );
176
- const unknownTokens = [ ...usedTokens ].filter(
177
- ( token ) => ! knownTokens.has( token )
178
- );
179
-
180
- if ( unknownTokens.length > 0 ) {
181
- context.report( {
182
- node,
183
- messageId: 'onlyKnownTokens',
184
- data: {
185
- tokenNames: unknownTokens
186
- .map( ( token ) => `'${ token }'` )
187
- .join( ', ' ),
188
- },
189
- } );
190
- }
191
-
192
163
  // Skip bare-token check for property keys
193
164
  // (e.g. `{ '--wpds-token': value }` declaring a custom property).
194
165
  const isPropertyKey =
195
166
  node.parent?.type === 'Property' &&
196
167
  node.parent.key === node;
197
-
198
- if ( ! isPropertyKey ) {
199
- const bareTokens = [ ...usedTokens ].filter(
200
- ( token ) =>
201
- knownTokens.has( token ) && bare.has( token )
202
- );
203
-
204
- if ( bareTokens.length > 0 ) {
205
- context.report( {
206
- node,
207
- messageId: 'bareToken',
208
- data: {
209
- tokenNames: bareTokens
210
- .map( ( token ) => `'${ token }'` )
211
- .join( ', ' ),
212
- },
213
- } );
168
+ const { unknownTokens, bareTokens } = getInvalidTokenNames(
169
+ occurrences,
170
+ {
171
+ includeBareTokens: ! isPropertyKey,
214
172
  }
215
- }
173
+ );
174
+
175
+ reportTokenNames(
176
+ context,
177
+ node,
178
+ 'onlyKnownTokens',
179
+ unknownTokens
180
+ );
181
+ reportTokenNames( context, node, 'bareToken', bareTokens );
216
182
  },
217
183
  };
218
184
  },
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Components tracked by this rule.
3
+ *
4
+ * @type {Set<string>}
5
+ */
6
+ const TRACKED_COMPONENTS = new Set( [ 'Link', 'Text', 'VisuallyHidden' ] );
7
+
8
+ /**
9
+ * @type {import('eslint').Rule.RuleModule}
10
+ */
11
+ module.exports = {
12
+ meta: {
13
+ type: 'problem',
14
+ docs: {
15
+ description:
16
+ 'Prevent render-prop composition orders that silently remove semantics.',
17
+ url: 'https://github.com/WordPress/gutenberg/blob/HEAD/packages/eslint-plugin/docs/rules/no-unsafe-render-order.md',
18
+ },
19
+ schema: [
20
+ {
21
+ type: 'object',
22
+ properties: {
23
+ checkLocalImports: {
24
+ type: 'boolean',
25
+ description:
26
+ 'When true, also checks tracked components imported from relative paths.',
27
+ },
28
+ },
29
+ additionalProperties: false,
30
+ },
31
+ ],
32
+ messages: {
33
+ visuallyHiddenOrder:
34
+ 'Do not pass `VisuallyHidden` via `render`. Make `VisuallyHidden` the outer component instead.',
35
+ linkTextOrder:
36
+ 'Use `Text` as the outer component and pass `Link` via `render` so the resulting element stays an `<a>`.',
37
+ },
38
+ },
39
+
40
+ create( context ) {
41
+ const checkLocalImports =
42
+ context.options[ 0 ]?.checkLocalImports ?? false;
43
+ const trackedImports = new Map();
44
+
45
+ /**
46
+ * @param {string} source
47
+ * @return {boolean} Whether the import should be tracked.
48
+ */
49
+ function shouldTrackImportSource( source ) {
50
+ if ( source === '@wordpress/ui' ) {
51
+ return true;
52
+ }
53
+
54
+ if ( checkLocalImports ) {
55
+ return source.startsWith( '.' ) || source.startsWith( '/' );
56
+ }
57
+
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * @param {import('estree-jsx').JSXIdentifier|import('estree-jsx').JSXMemberExpression} node
63
+ * @return {string|null} Tracked component name or null.
64
+ */
65
+ function resolveTrackedJsxName( node ) {
66
+ if ( node.type !== 'JSXIdentifier' ) {
67
+ return null;
68
+ }
69
+
70
+ return trackedImports.get( node.name ) ?? null;
71
+ }
72
+
73
+ /**
74
+ * @param {Array} attributes JSX attributes to inspect.
75
+ * @param {string} attributeName Attribute name to match.
76
+ * @return {import('estree-jsx').JSXAttribute|undefined} Matching attribute.
77
+ */
78
+ function getJsxAttribute( attributes, attributeName ) {
79
+ return attributes.find(
80
+ ( attribute ) =>
81
+ attribute.type === 'JSXAttribute' &&
82
+ attribute.name?.name === attributeName
83
+ );
84
+ }
85
+
86
+ /**
87
+ * @param {Array} attributes
88
+ * @return {string|null} Resolved JSX name inside `render={ <... /> }`.
89
+ */
90
+ function getRenderedComponentName( attributes ) {
91
+ const renderAttribute = getJsxAttribute( attributes, 'render' );
92
+
93
+ if (
94
+ ! renderAttribute?.value ||
95
+ renderAttribute.value.type !== 'JSXExpressionContainer' ||
96
+ renderAttribute.value.expression.type !== 'JSXElement'
97
+ ) {
98
+ return null;
99
+ }
100
+
101
+ return resolveTrackedJsxName(
102
+ renderAttribute.value.expression.openingElement.name
103
+ );
104
+ }
105
+
106
+ return {
107
+ ImportDeclaration( node ) {
108
+ const source = node.source.value;
109
+
110
+ if (
111
+ typeof source !== 'string' ||
112
+ ! shouldTrackImportSource( source )
113
+ ) {
114
+ return;
115
+ }
116
+
117
+ node.specifiers.forEach( ( specifier ) => {
118
+ if ( specifier.type === 'ImportSpecifier' ) {
119
+ const importedName = specifier.imported.name;
120
+
121
+ if ( TRACKED_COMPONENTS.has( importedName ) ) {
122
+ trackedImports.set(
123
+ specifier.local.name,
124
+ importedName
125
+ );
126
+ }
127
+ }
128
+ } );
129
+ },
130
+
131
+ JSXOpeningElement( node ) {
132
+ const renderedComponentName = getRenderedComponentName(
133
+ node.attributes
134
+ );
135
+
136
+ if ( renderedComponentName === 'VisuallyHidden' ) {
137
+ context.report( {
138
+ node,
139
+ messageId: 'visuallyHiddenOrder',
140
+ } );
141
+ return;
142
+ }
143
+
144
+ const elementName = resolveTrackedJsxName( node.name );
145
+
146
+ if (
147
+ elementName === 'Link' &&
148
+ renderedComponentName === 'Text'
149
+ ) {
150
+ context.report( {
151
+ node,
152
+ messageId: 'linkTextOrder',
153
+ } );
154
+ }
155
+ },
156
+ };
157
+ },
158
+ };
@@ -1,7 +1,7 @@
1
1
  /** @type {import('eslint').Rule.RuleModule} */
2
2
  module.exports = {
3
- type: 'problem',
4
3
  meta: {
4
+ type: 'problem',
5
5
  schema: [
6
6
  {
7
7
  type: 'object',