fix: preserve CSS escape sequences when printing selectors (#18667)

Fixes #18664

`print()` was writing selector names back out verbatim. The parser
decodes CSS escape sequences when it reads them — `\31` becomes `1`,
`\a` becomes a newline — so the printer produced selectors that either
failed to re-parse (`#123`) or silently meant something different
(`#line\nbreak` turns into a descendant combinator).

A small re-escaper (`escape_identifier`) is now used when printing type,
class, id, pseudo, attribute and at-rule names.

Also fixes a backslash parser bug in the process.

---------

Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
pull/18676/head
Marwan 4 weeks ago committed by GitHub
parent fe4a56b0a8
commit 8835003d41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: preserve CSS escape sequences when printing selectors

@ -614,7 +614,8 @@ function read_identifier(parser) {
if (char === '\\') {
const sequence = parser.match_regex(REGEX_UNICODE_SEQUENCE);
if (sequence) {
identifier += String.fromCodePoint(parseInt(sequence.slice(1), 16));
const character = String.fromCodePoint(parseInt(sequence.slice(1), 16));
identifier += character === '\\' ? '\\\\' : character;
parser.index += sequence.length;
} else {
identifier += '\\' + parser.template[parser.index + 1];

@ -7,6 +7,68 @@ import { is_void } from '../../utils.js';
/** Threshold for when content should be formatted on separate lines */
const LINE_BREAK_THRESHOLD = 50;
/** Characters that are valid in a CSS identifier without escaping */
const REGEX_IDENTIFIER_CHAR = /^[a-zA-Z0-9_-]$/;
/** Hex digits — a backslash followed by one of these is read as a hex escape */
const REGEX_HEX_DIGIT = /[0-9a-fA-F]/;
/**
* Re-escape a CSS identifier name so that it prints as valid CSS.
*
* `parse` decodes CSS escape sequences when building the AST `\31` becomes `1`,
* `\a` becomes a newline but keeps single-character escapes such as `\.` and
* escaped backslashes intact. When printing we therefore only need to escape the
* characters that would be illegal in a bare identifier: a leading digit, `-`
* followed by a digit, whitespace and control characters, and anything else that
* is not already escaped.
* @param {string} name
*/
function escape_identifier(name) {
let escaped = '';
let i = 0;
while (i < name.length) {
const char = name[i];
if (char === '\\') {
const next = name.charAt(i + 1);
if (next === '' || REGEX_HEX_DIGIT.test(next)) {
// A literal backslash in a name must itself be escaped: `\5c `
// re-parses to a backslash, whereas a backslash followed by a hex
// digit (or by nothing) would be read back as a hex escape.
escaped += '\\5c ';
i += 1;
continue;
}
// Already escaped — copy the backslash and the escaped character as-is.
escaped += '\\' + next;
i += 2;
continue;
}
const code = /** @type {number} */ (char.codePointAt(0));
const is_leading_digit = i === 0 && char >= '0' && char <= '9';
const is_leading_hyphen_digit =
i === 0 && char === '-' && name.charAt(i + 1) >= '0' && name.charAt(i + 1) <= '9';
if (
is_leading_digit ||
is_leading_hyphen_digit ||
!(REGEX_IDENTIFIER_CHAR.test(char) || code >= 160)
) {
escaped += `\\${code.toString(16)} `;
} else {
escaped += char;
}
i += 1;
}
return escaped;
}
/**
* `print` converts a Svelte AST node back into Svelte source code.
* It is primarily intended for tools that parse and transform components using the compilers modern AST representation.
@ -324,7 +386,7 @@ function css_visitors(comments, js_comments) {
return {
Atrule(node, context) {
context.write(`@${node.name}`);
context.write(`@${escape_identifier(node.name)}`);
const prelude_end = node.block?.start ?? node.end;
if (node.prelude || has_comment_before(prelude_end)) {
@ -341,7 +403,7 @@ function css_visitors(comments, js_comments) {
},
AttributeSelector(node, context) {
context.write(`[${node.name}`);
context.write(`[${escape_identifier(node.name)}`);
if (node.matcher) {
context.write(node.matcher);
context.write(`"${node.value}"`);
@ -365,7 +427,7 @@ function css_visitors(comments, js_comments) {
},
ClassSelector(node, context) {
context.write(`.${node.name}`);
context.write(`.${escape_identifier(node.name)}`);
},
ComplexSelector(node, context) {
@ -379,7 +441,7 @@ function css_visitors(comments, js_comments) {
},
IdSelector(node, context) {
context.write(`#${node.name}`);
context.write(`#${escape_identifier(node.name)}`);
},
NestingSelector(node, context) {
@ -395,7 +457,7 @@ function css_visitors(comments, js_comments) {
},
PseudoClassSelector(node, context) {
context.write(`:${node.name}`);
context.write(`:${escape_identifier(node.name)}`);
if (node.args) {
context.write('(');
@ -409,7 +471,7 @@ function css_visitors(comments, js_comments) {
},
PseudoElementSelector(node, context) {
context.write(`::${node.name}`);
context.write(`::${escape_identifier(node.name)}`);
if (node.args) {
context.write('(');
context.visit(node.args);
@ -458,7 +520,7 @@ function css_visitors(comments, js_comments) {
},
TypeSelector(node, context) {
context.write(node.name);
context.write(node.name === '*' ? node.name : escape_identifier(node.name));
}
};
}

@ -0,0 +1,25 @@
<div></div>
<style>
#\31\32\33 { color: green; }
#\31 23 { color: green; }
#line\a break { color: green; }
#line\a
break { color: green; }
li\.foo { color: green; }
.a\1f642 b { color: green; }
.a🙂b { color: green; }
#\2d 1foo { color: red; }
.a\5c b { color: red; }
.a\\b { color: red; }
.a\5c q { color: red; }
.a\\q { color: red; }
.a\5c { color: red; }
.a\\ { color: red; }
#\5c a { color: red; }
[\31 data] { color: red; }
:\31 st-child { color: red; }
* { color: red; }
#id-selector { color: red; }
[data-attribute="value"] { color: red; }
</style>

@ -0,0 +1,83 @@
<div></div>
<style>
#\31 23 {
color: green;
}
#\31 23 {
color: green;
}
#line\a break {
color: green;
}
#line\a break {
color: green;
}
li\.foo {
color: green;
}
.a🙂b {
color: green;
}
.a🙂b {
color: green;
}
#\2d 1foo {
color: red;
}
.a\\b {
color: red;
}
.a\\b {
color: red;
}
.a\\q {
color: red;
}
.a\\q {
color: red;
}
.a\\ {
color: red;
}
.a\\ {
color: red;
}
#\\a {
color: red;
}
[\31 data] {
color: red;
}
:\31 st-child {
color: red;
}
* {
color: red;
}
#id-selector {
color: red;
}
[data-attribute="value"] {
color: red;
}
</style>

@ -12,6 +12,10 @@ const { test, run } = suite<PrintTest>(async (config, cwd) => {
const output = print(ast);
const outputCode = output.code.endsWith('\n') ? output.code : output.code + '\n';
// the printed output must itself be valid Svelte — `print` should never emit
// code that `parse` cannot read back (e.g. CSS escape sequences must round-trip)
parse(outputCode, { modern: true });
// run `UPDATE_SNAPSHOTS=true pnpm test print` to update print tests
if (process.env.UPDATE_SNAPSHOTS) {
fs.writeFileSync(`${cwd}/output.svelte`, outputCode);

Loading…
Cancel
Save