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
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading