diff --git a/packages/eslint-plugin/CHANGELOG.md b/packages/eslint-plugin/CHANGELOG.md index d5df4223ed002e..31b65b81d9f68c 100644 --- a/packages/eslint-plugin/CHANGELOG.md +++ b/packages/eslint-plugin/CHANGELOG.md @@ -8,6 +8,7 @@ ### Enhancements +- The `no-unknown-ds-tokens` rule now reports bare `--wpds-*` tokens not wrapped in `var()`, which would silently miss build-time fallback injection. - The `no-setting-ds-tokens` rule now checks all object property keys, not just those inside JSX `style` attributes ([#76212](https://github.com/WordPress/gutenberg/pull/76212)). ## 24.3.0 (2026-03-04) @@ -44,7 +45,7 @@ ### Enhancements -- The `dependency-group` rule is not recommended anymore. ([#73616](https://github.com/WordPress/gutenberg/pull/73616)) +- The `dependency-group` rule is not recommended anymore. ([#73616](https://github.com/WordPress/gutenberg/pull/73616)) ## 22.22.0 (2025-11-26) 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 8293473cbd3a2c..8b6b53a5d60f4e 100644 --- a/packages/eslint-plugin/docs/rules/no-unknown-ds-tokens.md +++ b/packages/eslint-plugin/docs/rules/no-unknown-ds-tokens.md @@ -4,6 +4,8 @@ When using Design System tokens (CSS custom properties beginning with `--wpds-`) 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. +Tokens must also be wrapped in `var()` syntax (e.g. `var(--wpds-color-fg-content-neutral)`). The build tooling relies on this pattern to inject fallback values so that components render correctly without a ThemeProvider. Bare token references like `'--wpds-color-fg-content-neutral'` will not receive 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 @@ -27,6 +29,11 @@ const token = 'var(--wpds-nonexistent-token)'; const token = `var(--wpds-dimension-gap-${ size })`; ``` +```js +// Bare tokens without var() won't receive build-time fallbacks. +const token = '--wpds-color-fg-content-neutral'; +``` + Examples of **correct** code for this rule: ```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 0b345744d3dc41..64d094a579487b 100644 --- a/packages/eslint-plugin/rules/__tests__/no-unknown-ds-tokens.js +++ b/packages/eslint-plugin/rules/__tests__/no-unknown-ds-tokens.js @@ -39,6 +39,9 @@ ruleTester.run( 'no-unknown-ds-tokens', rule, { { code: '`var(--wpds-color-fg-content-neutral) ${ suffix }`', }, + { + code: `const style = { '--wpds-color-fg-content-neutral': 'red' };`, + }, ], invalid: [ { @@ -142,5 +145,60 @@ ruleTester.run( 'no-unknown-ds-tokens', rule, { }, ], }, + { + code: `const token = '--wpds-color-fg-content-neutral';`, + errors: [ + { + messageId: 'bareToken', + data: { + tokenNames: "'--wpds-color-fg-content-neutral'", + }, + }, + ], + }, + { + code: 'const token = `--wpds-color-fg-content-neutral`;', + errors: [ + { + messageId: 'bareToken', + data: { + tokenNames: "'--wpds-color-fg-content-neutral'", + }, + }, + ], + }, + { + code: '
', + errors: [ + { + messageId: 'bareToken', + data: { + tokenNames: "'--wpds-color-fg-content-neutral'", + }, + }, + ], + }, + { + code: '`${ prefix }: --wpds-color-fg-content-neutral`', + errors: [ + { + messageId: 'bareToken', + data: { + tokenNames: "'--wpds-color-fg-content-neutral'", + }, + }, + ], + }, + { + code: '`var(--wpds-color-fg-content-neutral) --wpds-color-fg-content-neutral ${ x }`', + errors: [ + { + messageId: 'bareToken', + data: { + tokenNames: "'--wpds-color-fg-content-neutral'", + }, + }, + ], + }, ], } ); diff --git a/packages/eslint-plugin/rules/no-unknown-ds-tokens.js b/packages/eslint-plugin/rules/no-unknown-ds-tokens.js index aa7bb9d280ede3..b374ebdcfd745d 100644 --- a/packages/eslint-plugin/rules/no-unknown-ds-tokens.js +++ b/packages/eslint-plugin/rules/no-unknown-ds-tokens.js @@ -4,35 +4,41 @@ const tokenList = tokenListModule.default || tokenListModule; const DS_TOKEN_PREFIX = 'wpds-'; /** - * Extracts all unique CSS custom properties (variables) from a given CSS value string, - * including those in fallback positions, optionally filtering by a specific prefix. + * Single-pass extraction that finds all `--prefix-*` tokens in a CSS value + * string and classifies each occurrence as `var()`-wrapped or bare. * - * @param {string} value - The CSS value string to search for variables. + * @param {string} value - The CSS value string to search. * @param {string} [prefix=''] - Optional prefix to filter variables (e.g., 'wpds-'). - * @return {Set} A Set of unique matched CSS variable names (e.g., Set { '--wpds-token' }). + * @return {{ tokens: Set, bare: Set }} + * `tokens` — every unique matched token; + * `bare` — the subset that appeared at least once without a `var()` wrapper. * * @example - * extractCSSVariables( - * 'border: 1px solid var(--wpds-border-color, var(--wpds-border-fallback)); ' + - * 'color: var(--wpds-color-fg, black); ' + - * 'background: var(--unrelated-bg);', - * 'wpds' + * classifyTokens( + * 'var(--wpds-color-fg) --wpds-color-bg', + * 'wpds-' * ); - * // → Set { '--wpds-border-color', '--wpds-border-fallback', '--wpds-color-fg' } + * // → { tokens: Set {'--wpds-color-fg','--wpds-color-bg'}, + * // bare: Set {'--wpds-color-bg'} } */ -function extractCSSVariables( value, prefix = '' ) { - const regex = /--[\w-]+/g; - const variables = new Set(); +function classifyTokens( value, prefix = '' ) { + const regex = new RegExp( + `(?:^|[^\\w])(var\\(\\s*)?(--${ prefix }[\\w-]+)`, + 'g' + ); + const tokens = new Set(); + const bare = new Set(); let match; while ( ( match = regex.exec( value ) ) !== null ) { - const variableName = match[ 0 ]; - if ( variableName.startsWith( `--${ prefix }` ) ) { - variables.add( variableName ); + const token = match[ 2 ]; + tokens.add( token ); + if ( ! match[ 1 ] ) { + bare.add( token ); } } - return variables; + return { tokens, bare }; } const knownTokens = new Set( tokenList ); @@ -50,6 +56,8 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( { '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.', + bareToken: + 'Design System tokens must be wrapped in `var()` for build-time fallback injection to work: {{ tokenNames }}', }, }, create( context ) { @@ -71,6 +79,7 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( { [ dynamicTemplateLiteralAST ]( node ) { let hasDynamic = false; const unknownTokens = []; + const bareTokens = []; for ( const quasi of node.quasis ) { const raw = quasi.value.raw; @@ -84,7 +93,7 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( { hasDynamic = true; } - const tokens = extractCSSVariables( + const { tokens, bare } = classifyTokens( value, DS_TOKEN_PREFIX ); @@ -95,12 +104,15 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( { const endMatch = value.match( /(--([\w-]+))$/ ); if ( endMatch ) { tokens.delete( endMatch[ 1 ] ); + bare.delete( endMatch[ 1 ] ); } } for ( const token of tokens ) { if ( ! knownTokens.has( token ) ) { unknownTokens.push( token ); + } else if ( bare.has( token ) ) { + bareTokens.push( token ); } } } @@ -123,6 +135,18 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( { }, } ); } + + if ( bareTokens.length > 0 ) { + context.report( { + node, + messageId: 'bareToken', + data: { + tokenNames: bareTokens + .map( ( token ) => `'${ token }'` ) + .join( ', ' ), + }, + } ); + } }, /** @param {import('estree').Literal | import('estree').TemplateElement} node */ [ staticTokensAST ]( node ) { @@ -145,7 +169,7 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( { return; } - const usedTokens = extractCSSVariables( + const { tokens: usedTokens, bare } = classifyTokens( computedValue, DS_TOKEN_PREFIX ); @@ -164,6 +188,31 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( { }, } ); } + + // Skip bare-token check for property keys + // (e.g. `{ '--wpds-token': value }` declaring a custom property). + const isPropertyKey = + node.parent?.type === 'Property' && + node.parent.key === node; + + if ( ! isPropertyKey ) { + const bareTokens = [ ...usedTokens ].filter( + ( token ) => + knownTokens.has( token ) && bare.has( token ) + ); + + if ( bareTokens.length > 0 ) { + context.report( { + node, + messageId: 'bareToken', + data: { + tokenNames: bareTokens + .map( ( token ) => `'${ token }'` ) + .join( ', ' ), + }, + } ); + } + } }, }; },