-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Fix token fallback plugins rewriting var() inside string literals #82209
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| } | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 regressionThis 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 |
||
|
|
||
| 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 })`; | ||
| } | ||
There was a problem hiding this comment.
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?