diff --git a/.changeset/calm-foxes-reprint.md b/.changeset/calm-foxes-reprint.md new file mode 100644 index 0000000000..08ea964bfe --- /dev/null +++ b/.changeset/calm-foxes-reprint.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: preserve CSS escape sequences when printing selectors \ No newline at end of file diff --git a/packages/svelte/src/compiler/phases/1-parse/read/style.js b/packages/svelte/src/compiler/phases/1-parse/read/style.js index 5eb8c2b3e9..02e6d8d064 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/style.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/style.js @@ -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]; diff --git a/packages/svelte/src/compiler/print/index.js b/packages/svelte/src/compiler/print/index.js index 8759fe3013..c6964fc0b7 100644 --- a/packages/svelte/src/compiler/print/index.js +++ b/packages/svelte/src/compiler/print/index.js @@ -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 compiler’s 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)); } }; } diff --git a/packages/svelte/tests/print/samples/css-escape-sequences/input.svelte b/packages/svelte/tests/print/samples/css-escape-sequences/input.svelte new file mode 100644 index 0000000000..db8e3e16a5 --- /dev/null +++ b/packages/svelte/tests/print/samples/css-escape-sequences/input.svelte @@ -0,0 +1,25 @@ +
+ + diff --git a/packages/svelte/tests/print/samples/css-escape-sequences/output.svelte b/packages/svelte/tests/print/samples/css-escape-sequences/output.svelte new file mode 100644 index 0000000000..532d0a2562 --- /dev/null +++ b/packages/svelte/tests/print/samples/css-escape-sequences/output.svelte @@ -0,0 +1,83 @@ +
+ + diff --git a/packages/svelte/tests/print/test.ts b/packages/svelte/tests/print/test.ts index aa007a7a54..4caecf90ae 100644 --- a/packages/svelte/tests/print/test.ts +++ b/packages/svelte/tests/print/test.ts @@ -12,6 +12,10 @@ const { test, run } = suite(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);