diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-value-normalization-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-value-normalization-test.js index 60a577e15..1198fc7fa 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-value-normalization-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-value-normalization-test.js @@ -325,6 +325,76 @@ describe('@stylexjs/babel-plugin', () => { `); }); + test('"content" property values containing quotes are wrapped in quotes', () => { + expect( + transform(` + import stylex from 'stylex'; + const styles = stylex.create({ + apostrophes: { + content: "Bob's and Jim's", + }, + embeddedQuote: { + content: 'He said "hello"', + }, + quoteKeywords: { + content: 'open-quote "hello" close-quote', + } + }); + `), + ).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + _inject2({ + ltr: ".x5jgoue{content:\\"Bob's and Jim's\\"}", + priority: 3000 + }); + _inject2({ + ltr: ".x1ooro1k{content:\\"He said \\\\\\"hello\\\\\\"\\"}", + priority: 3000 + }); + _inject2({ + ltr: ".x1iyhvvg{content:open-quote \\"hello\\" close-quote}", + priority: 3000 + });" + `); + }); + + test('"content" property values keep their CSS escape sequences', () => { + expect( + transform(` + import stylex from 'stylex'; + const styles = stylex.create({ + emDash: { + content: '\\\\2014', + }, + curlyQuotes: { + content: '\\\\201C hello \\\\201D', + }, + trailingBackslash: { + content: '50% off \\\\', + }, + }); + `), + ).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + _inject2({ + ltr: ".x1v4x2nj{content:\\"\\\\2014\\"}", + priority: 3000 + }); + _inject2({ + ltr: ".x1uxbif5{content:\\"\\\\201C hello \\\\201D\\"}", + priority: 3000 + }); + _inject2({ + ltr: ".x1y6ogk6{content:\\"50% off \\\\\\\\\\"}", + priority: 3000 + });" + `); + }); + test('[legacy] no space before "!important"', () => { expect( transform(` diff --git a/packages/@stylexjs/babel-plugin/src/shared/utils/__tests__/transform-value-test.js b/packages/@stylexjs/babel-plugin/src/shared/utils/__tests__/transform-value-test.js index 8b6df2ec8..9be33526f 100644 --- a/packages/@stylexjs/babel-plugin/src/shared/utils/__tests__/transform-value-test.js +++ b/packages/@stylexjs/babel-plugin/src/shared/utils/__tests__/transform-value-test.js @@ -75,6 +75,57 @@ describe('transformValue content property tests', () => { }); }); + test('adds quotes to plain strings containing quote characters', () => { + const strings = [ + ["Bob's and Jim's", '"Bob\'s and Jim\'s"'], + ["It's a test, isn't it", '"It\'s a test, isn\'t it"'], + ['He said "hello"', '"He said \\"hello\\""'], + ['say "hi" now', '"say \\"hi\\" now"'], + ['"hello" is what he said', '"\\"hello\\" is what he said"'], + ]; + + strings.forEach(([input, expected]) => { + expect(transformValue('content', input, {})).toBe(expected); + }); + }); + + test('preserves CSS escape sequences when adding quotes', () => { + const strings = [ + // Inside a CSS string a backslash starts an escape sequence. `\2014` is + // the escape for an em dash and `\201C` for a left double quotation + // mark, so escaping the backslash would print the digits instead. + ['\\2014', '"\\2014"'], + ['\\201C hello \\201D', '"\\201C hello \\201D"'], + ['back\\slash', '"back\\slash"'], + // `\\` is the escape for a literal backslash and stays one escape. + ['C:\\\\Users', '"C:\\\\Users"'], + // A double quote the author already escaped is escaped once, not twice. + ['He said \\"hello\\"', '"He said \\"hello\\""'], + // A trailing backslash would escape the closing quote, so it is doubled + // into the escape for a literal backslash. + ['50% off \\', '"50% off \\\\"'], + // A CSS string cannot hold a line break, so it is written as `\A`. + ['line one\nline two', '"line one\\A line two"'], + ]; + + strings.forEach(([input, expected]) => { + expect(transformValue('content', input, {})).toBe(expected); + }); + }); + + test('preserves quote keywords combined with strings', () => { + const values = [ + '"a" "b"', + 'open-quote "hello"', + '"prefix" no-close-quote', + 'open-quote "text" close-quote', + ]; + + values.forEach((input) => { + expect(transformValue('content', input, {})).toBe(input); + }); + }); + test('preserve units in zero values CSS variables', () => { const variables = [ ['--test', '0px', '0px'], diff --git a/packages/@stylexjs/babel-plugin/src/shared/utils/transform-value.js b/packages/@stylexjs/babel-plugin/src/shared/utils/transform-value.js index d9340b265..0236dfbfd 100644 --- a/packages/@stylexjs/babel-plugin/src/shared/utils/transform-value.js +++ b/packages/@stylexjs/babel-plugin/src/shared/utils/transform-value.js @@ -10,6 +10,7 @@ import type { StyleXOptions } from '../common-types'; import normalizeValue from './normalize-value'; +import parser from 'postcss-value-parser'; /** * Convert a CSS value in JS to the final CSS string value @@ -60,14 +61,46 @@ export default function transformValue( val.includes(func), ); const isKeyword = cssContentKeywords.has(val); - const hasMatchingQuotes = - (val.match(/"/g)?.length ?? 0) >= 2 || - (val.match(/'/g)?.length ?? 0) >= 2; - if (isCssFunction || isKeyword || hasMatchingQuotes) { + // A value the author already wrote as CSS is a list of content components: + // quoted strings, functions and quote keywords. Counting quote characters + // is not enough to detect that, because ordinary text can contain a pair of + // them, as in "Bob's and Jim's" or 'He said "hello"'. Such a value is not a + // CSS string, so the browser drops the whole declaration. + const contentNodes = parser(val).nodes.filter( + (node) => node.type !== 'space' && node.type !== 'div', + ); + const isQuotedContentList = + contentNodes.some((node) => node.type === 'string' && !node.unclosed) && + contentNodes.every((node) => { + if (node.type === 'string' || node.type === 'function') { + return !node.unclosed; + } + return node.type === 'word' && cssContentKeywords.has(node.value); + }); + + if (isCssFunction || isKeyword || isQuotedContentList) { return val; } - return `"${val}"`; + + // Inside a CSS string a backslash starts an escape sequence, so an escape + // the author wrote, such as `\2014` for an em dash, is left as it is. Only + // what would end the string early is escaped: a double quote that is not + // already escaped, a trailing backslash that would otherwise escape the + // closing quote, and a line break, which a CSS string writes as `\A`. + const escaped = val.replace( + /\\(?:\r\n|[\s\S])|\r\n|[\n\r\f]|["\\]/g, + (match) => { + if (match.length > 1 && match[0] === '\\') { + return match; + } + if (match === '"' || match === '\\') { + return `\\${match}`; + } + return '\\A '; + }, + ); + return `"${escaped}"`; } return normalizeValue(value, key, options);