Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/theme/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Bug Fixes

- Token fallback plugins now ignore `var(--wpds-*)` text inside quoted CSS strings and JS comments. ([#82209](https://github.com/WordPress/gutenberg/pull/82209))

### Documentation

- Explain how consumers define light and dark themes through `ThemeProvider` color seeds ([#82039](https://github.com/WordPress/gutenberg/pull/82039)).
Expand Down
318 changes: 296 additions & 22 deletions packages/theme/postcss-plugins/add-fallback-to-var.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,38 +10,312 @@
* map as an argument. For the variant prebound with the package's
* generated token fallback map, see `./ds-token-fallbacks.mjs`.
*
* @param {string} cssValue A CSS declaration value.
* @param {string} cssValue A CSS declaration value or JS/TS source.
* @param {Record<string, string>} tokenFallbacks Map of CSS variable names to their fallback expressions.
* @param {Object} [options] Options.
* @param {boolean} [options.escapeQuotes] When true, escape `"` and `'` in fallback values.
* Use this when the input is JS/TS source so that
* injected quotes don't break string literals. JS
* will unescape them at parse time, so the browser's
* CSS engine still sees the correct value.
* @param {boolean} [options.escapeQuotes] When true, treat the input as JS/TS source so that
* only CSS-like `var()` references outside comments
* and inside string/template literals are rewritten.
* Fallback quotes are escaped for JS string literals.
* @return {string} The value with fallbacks injected.
*/
export function addFallbackToVar(
cssValue,
tokenFallbacks,
{ escapeQuotes = false } = {}
) {
return cssValue.replace(
/var\(\s*(--wpds-[\w-]+)\s*\)/g,
( match, tokenName ) => {
let fallback = tokenFallbacks[ tokenName ];
if ( fallback === undefined ) {
throw new Error(
`Unknown design token: ${ tokenName }. ` +
'This token is not in the design system. ' +
'If this token was recently renamed, update all references to use the new name.'
);
if ( escapeQuotes ) {
return transformJsSource( cssValue, tokenFallbacks, { escapeQuotes } );
}

return transformCssValue( cssValue, tokenFallbacks, { escapeQuotes } );
}

const BARE_WPDS_VAR_PATTERN = /^var\(\s*(--wpds-[\w-]+)\s*\)/;

/**
* @param {string} cssValue
* @param {Record<string, string>} tokenFallbacks
* @param {Object} options
* @param {boolean} options.escapeQuotes
* @return {string} The CSS value with fallbacks injected.
*/
function transformCssValue( cssValue, tokenFallbacks, { escapeQuotes } ) {
let result = '';
let index = 0;

while ( index < cssValue.length ) {
const char = cssValue[ index ];

if ( char === '"' || char === "'" ) {
const end = readQuotedSegment( cssValue, index );
result += cssValue.slice( index, end );
index = end;
continue;
}

const match = cssValue.slice( index ).match( BARE_WPDS_VAR_PATTERN );
if ( match ) {
result += wrapVarWithFallback(
match[ 1 ],
tokenFallbacks,
escapeQuotes
);
index += match[ 0 ].length;
continue;
}

result += char;
index += 1;
}

return result;
}

/**
* @param {string} source
* @param {Record<string, string>} tokenFallbacks
* @param {Object} options
* @param {boolean} options.escapeQuotes
* @return {string} The JS/TS source with fallbacks injected inside string literals.
*/
function transformJsSource( source, tokenFallbacks, options ) {
let result = '';
let index = 0;

while ( index < source.length ) {
const char = source[ index ];
const next = source[ index + 1 ];

if ( char === '/' && next === '/' ) {
const end = source.indexOf( '\n', index );
const sliceEnd = end === -1 ? source.length : end;
result += source.slice( index, sliceEnd );
index = sliceEnd;
continue;
}

if ( char === '/' && next === '*' ) {
const end = source.indexOf( '*/', index + 2 );
const sliceEnd = end === -1 ? source.length : end + 2;
result += source.slice( index, sliceEnd );
index = sliceEnd;
continue;
}

if ( char === '"' || char === "'" ) {
const end = readQuotedSegment( source, index );
const literal = source.slice( index, end );
const quote = literal[ 0 ];
const inner = literal.slice( 1, -1 );
const transformed = transformCssValue(
inner,
tokenFallbacks,
options
);
result += quote + transformed + quote;
index = end;
continue;
}
Comment on lines +90 to +119

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.

This scanner treats quote and comment markers inside regular-expression literals as JavaScript syntax.

For example, const re = /["']/; const s = 'var(--wpds-border-radius-sm)'; becomes invalid JavaScript.

Maybe we should use a real JS/TS parser or lexer to locate string and template contents and add regex-literal coverage?


if ( char === '`' ) {
const templateLiteral = transformTemplateLiteral(
source,
index,
tokenFallbacks,
options
);
result += templateLiteral.content;
index = templateLiteral.end;
continue;
}

result += char;
index += 1;
}

return result;
}

/**
* @param {string} value
* @param {number} start
* @return {number} Index immediately after the closing quote.
*/
function readQuotedSegment( value, start ) {
const quote = value[ start ];
let index = start + 1;

while ( index < value.length ) {
if ( value[ index ] === '\\' ) {
index += 2;
continue;
}

if ( value[ index ] === quote ) {
return index + 1;
}

index += 1;
}

return value.length;
}

/**
* @param {string} source
* @param {number} start
* @param {Record<string, string>} tokenFallbacks
* @param {Object} options
* @param {boolean} options.escapeQuotes
* @return {{ content: string, end: number }} Transformed template literal and end index.
*/
function transformTemplateLiteral( source, start, tokenFallbacks, options ) {
let content = '`';
let index = start + 1;

while ( index < source.length ) {
if ( source[ index ] === '\\' ) {
content += source.slice( index, index + 2 );
index += 2;
continue;
}

if ( source[ index ] === '`' ) {
content += '`';
return { content, end: index + 1 };
}

if ( source[ index ] === '$' && source[ index + 1 ] === '{' ) {
const expressionEnd = readTemplateExpression( source, index + 2 );
content += source.slice( index, expressionEnd );
index = expressionEnd;
continue;
}
Comment on lines +189 to +194

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.

Copying each template expression unchanged means CSS strings nested inside it no longer receive fallbacks. Please keep transforming string and template literals inside expressions and add a regression test for a conditional CSS value.

Example of a regression

This input was transformed at the base revision but is left unchanged at this head:

const styles = `${ enabled ? 'var(--wpds-border-radius-sm)' : '' }`;

The published esbuild and Vite plugins support JS/TS source, and @wordpress/build enables the esbuild plugin automatically when @wordpress/theme is installed. Leaving this reference bare defeats the fallback plugin when the design-token stylesheet is unavailable.


const segmentStart = index;
while ( index < source.length ) {
if ( source[ index ] === '\\' ) {
index += 2;
continue;
}
if ( escapeQuotes ) {
fallback = fallback
.replaceAll( '"', '\\"' )
.replaceAll( "'", "\\'" );

if (
source[ index ] === '`' ||
( source[ index ] === '$' && source[ index + 1 ] === '{' )
) {
break;
}

index += 1;
}

content += transformCssValue(
source.slice( segmentStart, index ),
tokenFallbacks,
options
);
}

content += '`';
return { content, end: source.length };
}

/**
* @param {string} source
* @param {number} start
* @return {number} Index immediately after the closing brace.
*/
function readTemplateExpression( source, start ) {
let depth = 1;
let index = start;

while ( index < source.length && depth > 0 ) {
const char = source[ index ];
const next = source[ index + 1 ];

if ( char === '/' && next === '/' ) {
const end = source.indexOf( '\n', index );
index = end === -1 ? source.length : end;
continue;
}

if ( char === '/' && next === '*' ) {
const end = source.indexOf( '*/', index + 2 );
index = end === -1 ? source.length : end + 2;
continue;
}

if ( char === '"' || char === "'" || char === '`' ) {
if ( char === '`' ) {
index = readTemplateLiteralEnd( source, index );
continue;
}
return `var(${ tokenName }, ${ fallback })`;

index = readQuotedSegment( source, index );
continue;
}
);

if ( char === '{' ) {
depth += 1;
} else if ( char === '}' ) {
depth -= 1;
}

index += 1;
}

return index;
}

/**
* @param {string} source
* @param {number} start
* @return {number} Index immediately after the closing backtick.
*/
function readTemplateLiteralEnd( source, start ) {
let index = start + 1;

while ( index < source.length ) {
if ( source[ index ] === '\\' ) {
index += 2;
continue;
}

if ( source[ index ] === '`' ) {
return index + 1;
}

if ( source[ index ] === '$' && source[ index + 1 ] === '{' ) {
index = readTemplateExpression( source, index + 2 );
continue;
}

index += 1;
}

return source.length;
}

/**
* @param {string} tokenName
* @param {Record<string, string>} tokenFallbacks
* @param {boolean} escapeQuotes
* @return {string} A var() call with the token fallback injected.
*/
function wrapVarWithFallback( tokenName, tokenFallbacks, escapeQuotes ) {
let fallback = tokenFallbacks[ tokenName ];
if ( fallback === undefined ) {
throw new Error(
`Unknown design token: ${ tokenName }. ` +
'This token is not in the design system. ' +
'If this token was recently renamed, update all references to use the new name.'
);
}

if ( escapeQuotes ) {
fallback = fallback.replaceAll( '"', '\\"' ).replaceAll( "'", "\\'" );
}

return `var(${ tokenName }, ${ fallback })`;
}
Loading
Loading