Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/eslint-plugin/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions packages/eslint-plugin/docs/rules/no-unknown-ds-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
58 changes: 58 additions & 0 deletions packages/eslint-plugin/rules/__tests__/no-unknown-ds-tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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: '<div style={ { gap: `--wpds-color-fg-content-neutral` } } />',
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'",
},
},
],
},
],
} );
87 changes: 68 additions & 19 deletions packages/eslint-plugin/rules/no-unknown-ds-tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>} A Set of unique matched CSS variable names (e.g., Set { '--wpds-token' }).
* @return {{ tokens: Set<string>, bare: Set<string> }}
* `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 );
Expand All @@ -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 ) {
Expand All @@ -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;
Expand All @@ -84,7 +93,7 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( {
hasDynamic = true;
}

const tokens = extractCSSVariables(
const { tokens, bare } = classifyTokens(
value,
DS_TOKEN_PREFIX
);
Expand All @@ -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 );
}
}
}
Comment on lines 79 to 118
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the dynamic TemplateLiteral handler, unknownTokens / bareTokens are accumulated in arrays and can include duplicates when the same token appears in multiple quasis (e.g. before and after an expression). This can produce repeated entries in tokenNames and noisy/non-deterministic messages. Consider tracking these as Sets (and only formatting to a list at report time) to keep diagnostics unique and stable.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping this one since the same pattern already existed for unknownTokens before this PR. Could be a separate cleanup if it ever becomes a problem.

Expand All @@ -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 ) {
Expand All @@ -145,7 +169,7 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( {
return;
}

const usedTokens = extractCSSVariables(
const { tokens: usedTokens, bare } = classifyTokens(
computedValue,
DS_TOKEN_PREFIX
);
Expand All @@ -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( ', ' ),
},
} );
}
}
},
};
},
Expand Down
Loading