Skip to content
17 changes: 16 additions & 1 deletion packages/eslint-plugin/docs/rules/no-unknown-ds-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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.

I considered separating the two lint rules, but since a dynamically generated token will always evade an unknown tokens lint, I think it makes sense to keep the two lints as a single rule. The fallback injection thing is secondary — not necessarily the main motivator of this additional check.


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 @@ -12,16 +14,29 @@ Examples of **incorrect** code for this rule:
<div style={ { color: 'var(--wpds-nonexistent-token)' } } />
```

```js
const token = 'var(--wpds-nonexistent-token)';
```

```jsx
<div style={ { color: 'var(--wpds-fake-color, var(--wpds-also-fake))' } } />
```

```js
// Dynamically constructed token names are not allowed.
const token = `var(--wpds-dimension-gap-${ size })`;
```

Examples of **correct** code for this rule:

```jsx
<div style={ { color: 'var(--wpds-color-fg-content-neutral)' } } />
```

```js
const token = 'var(--wpds-color-fg-content-neutral)';
```

```jsx
<div style={ { color: 'var(--my-custom-prop)' } } />
```
Expand Down
37 changes: 37 additions & 0 deletions packages/eslint-plugin/rules/__tests__/no-unknown-ds-tokens.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @wordpress/no-unknown-ds-tokens */
import { RuleTester } from 'eslint';
import rule from '../no-unknown-ds-tokens';

Expand Down Expand Up @@ -27,6 +28,9 @@ ruleTester.run( 'no-unknown-ds-tokens', rule, {
{
code: '<div style={ { color: `var(--wpds-color-fg-content-neutral)` } } />',
},
{
code: `const token = 'var(--wpds-color-fg-content-neutral)';`,
},
],
invalid: [
{
Expand Down Expand Up @@ -56,5 +60,38 @@ ruleTester.run( 'no-unknown-ds-tokens', rule, {
},
],
},
{
code: `const token = 'var(--wpds-nonexistent-token)';`,
errors: [
{
messageId: 'onlyKnownTokens',
},
],
},
{
code: 'const token = `var(--wpds-nonexistent-token)`;',
errors: [
{
messageId: 'onlyKnownTokens',
},
],
},
{
code: 'const token = `var(--wpds-dimension-gap-${ size })`;',
errors: [
{
messageId: 'dynamicToken',
},
],
},
{
code: '<div style={ { gap: `var(--wpds-dimension-gap-${ size })` } } />',
errors: [
Comment on lines +99 to +109

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

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

Add a valid test case for a template literal that contains an expression but where the --wpds-* token reference is fully static (e.g. const css = `var(--wpds-color-fg-content-neutral) ${ suffix }`;). This guards against over-reporting dynamicToken for template literals that the fallback injection logic can still safely process.

Copilot uses AI. Check for mistakes.
{
messageId: 'dynamicToken',
},
],
},
],
} );
/* eslint-enable @wordpress/no-unknown-ds-tokens */
17 changes: 12 additions & 5 deletions packages/eslint-plugin/rules/no-unknown-ds-tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,33 +42,40 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ( {
meta: {
type: 'problem',
docs: {
description: 'Prevent use of non-existing --wpds-* variables',
description: 'Prevent use of non-existing --wpds-* variables', // eslint-disable-line @wordpress/no-unknown-ds-tokens
},
schema: [],
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 dynamicTokensAST = `TemplateLiteral[expressions.length>0] TemplateElement[value.raw=${ wpdsTokensRegex }]`;
const staticTokensAST = `:matches(Literal[value=${ wpdsTokensRegex }], TemplateLiteral[expressions.length=0] TemplateElement[value.raw=${ wpdsTokensRegex }])`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The logic here could generate false positives with a confusing message: the user would be told "tokens must not be dynamically constructed" even when the token isn't dynamic.

If a template literal has any expression, every TemplateElement in it containing --wpds- matches dynamicTokensAST, even if the token is fully static and the expression is elsewhere in the string. For example:

const style = `color: ${color}; gap: var(--wpds-dimension-gap-sm)`;

The template element "; gap: var(--wpds-dimension-gap-sm)" matches dynamicTokensAST and would be reported with the dynamicToken message, even though --wpds-dimension-gap-sm is entirely static and valid. Meanwhile, staticTokensAST won't catch it because expressions.length > 0.

Opus' suggestion:

A more precise approach could check whether the expression is actually adjacent to (i.e., part of) the --wpds-* token name. For instance, you could check if the TemplateElement's raw value ends with an incomplete token (like --wpds-dimension-gap-) rather than simply containing the prefix. Alternatively, the dynamicTokensAST handler could inspect the raw value to see whether --wpds- appears followed by a complete token name boundary (like ) or whitespace) — if so, it's static and should be validated against the token list, not flagged as dynamic.
At minimum, a test case for this scenario should be added (and probably marked as a known limitation if not addressed):

// Should be valid — the token is static, only another part is dynamic
`${ prefix }: var(--wpds-color-fg-content-neutral)`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(I guess same as #75905 (comment))

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.

return {
[ dynamicTokensAST ]( node ) {
context.report( {
node: node.parent,

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

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

Reporting on node.parent from the TemplateElement visitor can produce duplicate dynamicToken errors when a single template literal contains multiple --wpds- occurrences across multiple quasis (each matching TemplateElement[...]). Consider selecting/reporting the TemplateLiteral node directly (e.g. via a TemplateLiteral:has(...) selector) or tracking already-reported template literals to ensure only one report per template literal.

Suggested change
const dynamicTokensAST = `TemplateLiteral[expressions.length>0] TemplateElement[value.raw=${ wpdsTokensRegex }]`;
const staticTokensAST = `:matches(Literal[value=${ wpdsTokensRegex }], TemplateLiteral[expressions.length=0] TemplateElement[value.raw=${ wpdsTokensRegex }])`;
return {
[ dynamicTokensAST ]( node ) {
context.report( {
node: node.parent,
const dynamicTokensAST = `TemplateLiteral[expressions.length>0]:has(TemplateElement[value.raw=${ wpdsTokensRegex }])`;
const staticTokensAST = `:matches(Literal[value=${ wpdsTokensRegex }], TemplateLiteral[expressions.length=0] TemplateElement[value.raw=${ wpdsTokensRegex }])`;
return {
[ dynamicTokensAST ]( node ) {
context.report( {
node,

Copilot uses AI. Check for mistakes.
messageId: 'dynamicToken',
} );

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

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

dynamicTokensAST currently reports dynamicToken for any template literal that has expressions and contains a --wpds- segment in any quasi. This will also flag cases where the token itself is fully static (e.g. `var(--wpds-color-fg-content-neutral) ${suffix}`), even though the fallback injection regex can still process the static var(--wpds-...) substring. Consider tightening the detection to only report when the token name is actually interrupted by an expression (e.g. a quasi ends with a partial token like --wpds-...-), and add a regression test for the static-token-with-expression case.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

},
/** @param {import('estree').Literal | import('estree').TemplateElement} node */
[ disallowedTokensAST ]( node ) {
[ staticTokensAST ]( node ) {
let computedValue;

if ( ! node.value ) {
return;
}

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;
}

Expand Down
Loading