diff --git a/.eslintrc.js b/.eslintrc.js index 96b5f1d59a5fad..149d230349c77c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -554,5 +554,11 @@ module.exports = { '@wordpress/dependency-group': [ 'error', 'never' ], }, }, + { + files: [ 'packages/eslint-plugin/**', 'packages/theme/**' ], + rules: { + '@wordpress/no-unknown-ds-tokens': 'off', + }, + }, ], }; diff --git a/packages/eslint-plugin/CHANGELOG.md b/packages/eslint-plugin/CHANGELOG.md index dfa7f37bd6bab4..dbc73b2069c351 100644 --- a/packages/eslint-plugin/CHANGELOG.md +++ b/packages/eslint-plugin/CHANGELOG.md @@ -6,6 +6,10 @@ - Added [`no-ds-tokens`](https://github.com/WordPress/gutenberg/blob/HEAD/packages/eslint-plugin/docs/rules/no-ds-tokens.md) rule to disallow usage of Design System token CSS custom properties (`--wpds-*`). +### Enhancements + +- The `no-unknown-ds-tokens` rule now checks all string literals and template literals, not just JSX `style` attributes. It also reports dynamically constructed `--wpds-*` token names. + ## 24.2.0 (2026-02-18) ## 24.1.0 (2026-01-29) diff --git a/packages/eslint-plugin/docs/rules/no-unknown-ds-tokens.md b/packages/eslint-plugin/docs/rules/no-unknown-ds-tokens.md index f3b1486d65c9e4..8293473cbd3a2c 100644 --- a/packages/eslint-plugin/docs/rules/no-unknown-ds-tokens.md +++ b/packages/eslint-plugin/docs/rules/no-unknown-ds-tokens.md @@ -2,7 +2,9 @@ When using Design System tokens (CSS custom properties beginning with `--wpds-`), only valid public tokens should be used. Using non-existent tokens will result in broken styles since the CSS variable won't resolve to any value. -This rule lints JSX inline styles. For CSS files, use the [corresponding Stylelint rule](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-theme/#stylelint-plugins) from the `@wordpress/theme` package. +Additionally, token names must not be dynamically constructed (e.g. via template literal expressions), as they cannot be statically verified for correctness or processed automatically to inject fallbacks. + +This rule lints all string literals and template literals in JavaScript/TypeScript files. For CSS files, use the [corresponding Stylelint rule](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-theme/#stylelint-plugins) from the `@wordpress/theme` package. ## Rule details @@ -12,16 +14,29 @@ Examples of **incorrect** code for this rule:
``` +```js +const token = 'var(--wpds-nonexistent-token)'; +``` + ```jsx
``` +```js +// Dynamically constructed token names are not allowed. +const token = `var(--wpds-dimension-gap-${ size })`; +``` + Examples of **correct** code for this rule: ```jsx
``` +```js +const token = 'var(--wpds-color-fg-content-neutral)'; +``` + ```jsx
``` diff --git a/packages/eslint-plugin/rules/__tests__/no-unknown-ds-tokens.js b/packages/eslint-plugin/rules/__tests__/no-unknown-ds-tokens.js index 2688ac9382ddf9..0b345744d3dc41 100644 --- a/packages/eslint-plugin/rules/__tests__/no-unknown-ds-tokens.js +++ b/packages/eslint-plugin/rules/__tests__/no-unknown-ds-tokens.js @@ -27,6 +27,18 @@ ruleTester.run( 'no-unknown-ds-tokens', rule, { { code: '
', }, + { + code: `const token = 'var(--wpds-color-fg-content-neutral)';`, + }, + { + code: `const name = 'something--wpds-color';`, + }, + { + code: '`${ prefix }: var(--wpds-color-fg-content-neutral)`', + }, + { + code: '`var(--wpds-color-fg-content-neutral) ${ suffix }`', + }, ], invalid: [ { @@ -34,6 +46,9 @@ ruleTester.run( 'no-unknown-ds-tokens', rule, { errors: [ { messageId: 'onlyKnownTokens', + data: { + tokenNames: "'--wpds-nonexistent-token'", + }, }, ], }, @@ -53,6 +68,77 @@ ruleTester.run( 'no-unknown-ds-tokens', rule, { errors: [ { messageId: 'onlyKnownTokens', + data: { + tokenNames: "'--wpds-nonexistent'", + }, + }, + ], + }, + { + code: `const token = 'var(--wpds-nonexistent-token)';`, + errors: [ + { + messageId: 'onlyKnownTokens', + data: { + tokenNames: "'--wpds-nonexistent-token'", + }, + }, + ], + }, + { + code: 'const token = `var(--wpds-nonexistent-token)`;', + errors: [ + { + messageId: 'onlyKnownTokens', + data: { + tokenNames: "'--wpds-nonexistent-token'", + }, + }, + ], + }, + { + code: 'const token = `var(--wpds-dimension-gap-${ size })`;', + errors: [ + { + messageId: 'dynamicToken', + }, + ], + }, + { + code: '
', + errors: [ + { + messageId: 'dynamicToken', + }, + ], + }, + { + code: `const token = '--wpds-nonexistent-token';`, + errors: [ + { + messageId: 'onlyKnownTokens', + data: { + tokenNames: "'--wpds-nonexistent-token'", + }, + }, + ], + }, + { + code: 'const style = `--wpds-dimension-gap-${ size }`;', + errors: [ + { + messageId: 'dynamicToken', + }, + ], + }, + { + code: '`${ prefix }: var(--wpds-nonexistent-token)`', + errors: [ + { + messageId: 'onlyKnownTokens', + data: { + tokenNames: "'--wpds-nonexistent-token'", + }, }, ], }, diff --git a/packages/eslint-plugin/rules/no-unknown-ds-tokens.js b/packages/eslint-plugin/rules/no-unknown-ds-tokens.js index e13402087a6300..aa7bb9d280ede3 100644 --- a/packages/eslint-plugin/rules/no-unknown-ds-tokens.js +++ b/packages/eslint-plugin/rules/no-unknown-ds-tokens.js @@ -36,7 +36,7 @@ function extractCSSVariables( value, prefix = '' ) { } const knownTokens = new Set( tokenList ); -const wpdsTokensRegex = new RegExp( `[^\\w]--${ DS_TOKEN_PREFIX }`, 'i' ); +const wpdsTokensRegex = new RegExp( `(?:^|[^\\w])--${ DS_TOKEN_PREFIX }`, 'i' ); module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( { meta: { @@ -48,13 +48,84 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( { messages: { onlyKnownTokens: 'The following CSS variables are not valid Design System tokens: {{ tokenNames }}', + dynamicToken: + 'Design System tokens must not be dynamically constructed, as they cannot be statically verified for correctness or processed automatically to inject fallbacks.', }, }, create( context ) { - const disallowedTokensAST = `JSXAttribute[name.name="style"] :matches(Literal[value=${ wpdsTokensRegex }], TemplateLiteral TemplateElement[value.raw=${ wpdsTokensRegex }])`; + const dynamicTemplateLiteralAST = `TemplateLiteral[expressions.length>0]:has(TemplateElement[value.raw=${ wpdsTokensRegex }])`; + const staticTokensAST = `:matches(Literal[value=${ wpdsTokensRegex }], TemplateLiteral[expressions.length=0] TemplateElement[value.raw=${ wpdsTokensRegex }])`; + const dynamicTokenEndRegex = new RegExp( + `--${ DS_TOKEN_PREFIX }[\\w-]*$` + ); + return { + /** + * For template literals with expressions, check each quasi + * individually: flag as dynamic only when a `--wpds-*` token + * name is split across a quasi/expression boundary, and + * validate any complete static tokens normally. + * + * @param {import('estree').TemplateLiteral} node + */ + [ dynamicTemplateLiteralAST ]( node ) { + let hasDynamic = false; + const unknownTokens = []; + + for ( const quasi of node.quasis ) { + const raw = quasi.value.raw; + const value = quasi.value.cooked ?? raw; + const isFollowedByExpression = ! quasi.tail; + + if ( + isFollowedByExpression && + dynamicTokenEndRegex.test( raw ) + ) { + hasDynamic = true; + } + + const tokens = extractCSSVariables( + value, + DS_TOKEN_PREFIX + ); + + // Remove the trailing incomplete token — it's the one + // being dynamically constructed by the next expression. + if ( isFollowedByExpression ) { + const endMatch = value.match( /(--([\w-]+))$/ ); + if ( endMatch ) { + tokens.delete( endMatch[ 1 ] ); + } + } + + for ( const token of tokens ) { + if ( ! knownTokens.has( token ) ) { + unknownTokens.push( token ); + } + } + } + + if ( hasDynamic ) { + context.report( { + node, + messageId: 'dynamicToken', + } ); + } + + if ( unknownTokens.length > 0 ) { + context.report( { + node, + messageId: 'onlyKnownTokens', + data: { + tokenNames: unknownTokens + .map( ( token ) => `'${ token }'` ) + .join( ', ' ), + }, + } ); + } + }, /** @param {import('estree').Literal | import('estree').TemplateElement} node */ - [ disallowedTokensAST ]( node ) { + [ staticTokensAST ]( node ) { let computedValue; if ( ! node.value ) { @@ -62,13 +133,11 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( { } if ( typeof node.value === 'string' ) { - // Get the node's value when it's a "string" computedValue = node.value; } else if ( typeof node.value === 'object' && 'raw' in node.value ) { - // Get the node's value when it's a `template literal` computedValue = node.value.cooked ?? node.value.raw; }