From 2b2afcaa2f40288e47b02d25d2b803fae55feb97 Mon Sep 17 00:00:00 2001 From: Tee Ming Date: Sat, 14 Feb 2026 16:43:51 +0800 Subject: [PATCH 1/7] fix and test --- .changeset/sad-laws-kick.md | 5 +++ .../src/exports/vite/build/build_server.js | 4 +-- packages/kit/src/exports/vite/build/utils.js | 12 +++++++ .../kit/src/exports/vite/build/utils.spec.js | 32 +++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 .changeset/sad-laws-kick.md create mode 100644 packages/kit/src/exports/vite/build/utils.spec.js diff --git a/.changeset/sad-laws-kick.md b/.changeset/sad-laws-kick.md new file mode 100644 index 000000000000..33c834934e79 --- /dev/null +++ b/.changeset/sad-laws-kick.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: escape backticks and dollar signs when creating inlined css diff --git a/packages/kit/src/exports/vite/build/build_server.js b/packages/kit/src/exports/vite/build/build_server.js index 31f2af742fee..0b46abfabb7a 100644 --- a/packages/kit/src/exports/vite/build/build_server.js +++ b/packages/kit/src/exports/vite/build/build_server.js @@ -1,6 +1,6 @@ import fs from 'node:fs'; import { mkdirp } from '../../../utils/filesystem.js'; -import { filter_fonts, find_deps, resolve_symlinks } from './utils.js'; +import { create_dynamic_css, filter_fonts, find_deps, resolve_symlinks } from './utils.js'; import { s } from '../../../utils/misc.js'; import { normalizePath } from 'vite'; import { basename } from 'node:path'; @@ -78,7 +78,7 @@ export function build_server_nodes( // only convert to a function if we have adjusted any URLs if (css !== transformed_css) { - return `function css(assets, base) { return \`${s(transformed_css).slice(1, -1)}\`; }`; + return create_dynamic_css(transformed_css, ['assets', 'base']); } return s(css); }; diff --git a/packages/kit/src/exports/vite/build/utils.js b/packages/kit/src/exports/vite/build/utils.js index 962e36c7e39a..0dec9ecb0144 100644 --- a/packages/kit/src/exports/vite/build/utils.js +++ b/packages/kit/src/exports/vite/build/utils.js @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { normalizePath } from 'vite'; +import { s } from '../../../utils/misc.js'; /** * Adds transitive JS and CSS dependencies to the js and css inputs. @@ -129,3 +130,14 @@ export function filter_fonts(assets) { export function assets_base(config) { return (config.paths.assets || config.paths.base || '.') + '/'; } + +/** + * @param {string} css The CSS with string interpolations + * @param {string[]} args + * @returns {string} + */ +export function create_dynamic_css(css, args) { + const escaped_css = s(css).slice(1, -1).replaceAll('$', '\\$').replaceAll('`', '\\`'); + const fn_args = args ? args.join(', ') : ''; + return `function css(${fn_args}) { return \`${escaped_css}\`; }`; +} diff --git a/packages/kit/src/exports/vite/build/utils.spec.js b/packages/kit/src/exports/vite/build/utils.spec.js new file mode 100644 index 000000000000..ec6491f9665d --- /dev/null +++ b/packages/kit/src/exports/vite/build/utils.spec.js @@ -0,0 +1,32 @@ +import { assert, describe, test } from 'vitest'; +import { create_dynamic_css } from './utils.js'; + +describe('create_dynamic_css', () => { + test.each([ + { + name: 'escapes backticks', + input: "div:after { content: '`'; }", + expected: "function css() { return `div:after { content: '\\`'; }`; }" + }, + { + name: 'escapes interpolations', + input: "div:after { content: '${example}'; }", + expected: "function css() { return `div:after { content: '\\${example}'; }`; }" + }, + { + name: 'handles backslashes', + input: "div:after { content: '\\s'; }", + expected: "function css() { return `div:after { content: '\\\\s'; }`; }" + } + ])('$name', ({ input, expected }) => { + const code = create_dynamic_css(input, []); + assert.equal(code, expected); + + let css; + assert.doesNotThrow(() => { + css = eval(`(${code})()`); + }); + + assert.equal(css, input); + }); +}); From 62b92fd6ba29489fc6e680299684b4f408ebfb7a Mon Sep 17 00:00:00 2001 From: Tee Ming Date: Sat, 14 Feb 2026 17:09:10 +0800 Subject: [PATCH 2/7] we need to escape dollar signs earlier --- packages/kit/src/exports/vite/build/utils.js | 2 +- packages/kit/src/exports/vite/build/utils.spec.js | 5 ----- packages/kit/src/utils/css.js | 5 +++++ packages/kit/src/utils/css.spec.js | 9 +++++++++ 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/kit/src/exports/vite/build/utils.js b/packages/kit/src/exports/vite/build/utils.js index 0dec9ecb0144..01bf1d0b60b1 100644 --- a/packages/kit/src/exports/vite/build/utils.js +++ b/packages/kit/src/exports/vite/build/utils.js @@ -137,7 +137,7 @@ export function assets_base(config) { * @returns {string} */ export function create_dynamic_css(css, args) { - const escaped_css = s(css).slice(1, -1).replaceAll('$', '\\$').replaceAll('`', '\\`'); + const escaped_css = s(css).slice(1, -1).replaceAll('`', '\\`'); const fn_args = args ? args.join(', ') : ''; return `function css(${fn_args}) { return \`${escaped_css}\`; }`; } diff --git a/packages/kit/src/exports/vite/build/utils.spec.js b/packages/kit/src/exports/vite/build/utils.spec.js index ec6491f9665d..872fd7010c96 100644 --- a/packages/kit/src/exports/vite/build/utils.spec.js +++ b/packages/kit/src/exports/vite/build/utils.spec.js @@ -8,11 +8,6 @@ describe('create_dynamic_css', () => { input: "div:after { content: '`'; }", expected: "function css() { return `div:after { content: '\\`'; }`; }" }, - { - name: 'escapes interpolations', - input: "div:after { content: '${example}'; }", - expected: "function css() { return `div:after { content: '\\${example}'; }`; }" - }, { name: 'handles backslashes', input: "div:after { content: '\\s'; }", diff --git a/packages/kit/src/utils/css.js b/packages/kit/src/utils/css.js index 9953de3b6de1..4224c96141c0 100644 --- a/packages/kit/src/utils/css.js +++ b/packages/kit/src/utils/css.js @@ -59,6 +59,11 @@ export function fix_css_urls({ return css; } + // if we're doing string interpolation, we should escape existing $ signs first + if (paths_assets.includes('$') || base.includes('$')) { + css = css.replaceAll('$', '\\$'); + } + // safe guard in case of trailing slashes (but this should never happen) if (paths_assets.endsWith('/')) { paths_assets = paths_assets.slice(0, -1); diff --git a/packages/kit/src/utils/css.spec.js b/packages/kit/src/utils/css.spec.js index c840187931cd..467cd32e3316 100644 --- a/packages/kit/src/utils/css.spec.js +++ b/packages/kit/src/utils/css.spec.js @@ -169,6 +169,15 @@ describe('fix_css_urls', () => { css: 'div::before { content: "/*"; } div { background: blue /* url(./image.png) */; }', expected: 'div::before { content: "/*"; } div { background: blue /* url(./image.png) */; }', vite_assets: ['image.png'] + }, + { + name: 'escapes $ signs when interpolating', + css: 'div { background: url(./image.png); }\ndiv:after { content: "${example}"; }', + expected: + 'div { background: url(${assets}/image.png); }\ndiv:after { content: "\\${example}"; }', + vite_assets: ['image.png'], + paths_assets: '${assets}', + base: '${base}' } ])( '$name', From fa34fd9b17b8bd2ea4b0303dbeb7bf05b4863083 Mon Sep 17 00:00:00 2001 From: Tee Ming Date: Sun, 15 Feb 2026 23:55:15 +0800 Subject: [PATCH 3/7] clean up --- .../src/exports/vite/build/build_server.js | 5 +-- packages/kit/src/exports/vite/build/utils.js | 9 ++--- .../kit/src/exports/vite/build/utils.spec.js | 34 ++++++------------- packages/kit/src/utils/css.js | 5 +-- packages/kit/src/utils/css.spec.js | 9 ----- packages/kit/src/utils/escape.js | 9 +++++ packages/kit/src/utils/escape.spec.js | 9 ++++- 7 files changed, 39 insertions(+), 41 deletions(-) diff --git a/packages/kit/src/exports/vite/build/build_server.js b/packages/kit/src/exports/vite/build/build_server.js index 0b46abfabb7a..caa505f9f540 100644 --- a/packages/kit/src/exports/vite/build/build_server.js +++ b/packages/kit/src/exports/vite/build/build_server.js @@ -1,6 +1,6 @@ import fs from 'node:fs'; import { mkdirp } from '../../../utils/filesystem.js'; -import { create_dynamic_css, filter_fonts, find_deps, resolve_symlinks } from './utils.js'; +import { create_dynamic_string, filter_fonts, find_deps, resolve_symlinks } from './utils.js'; import { s } from '../../../utils/misc.js'; import { normalizePath } from 'vite'; import { basename } from 'node:path'; @@ -78,8 +78,9 @@ export function build_server_nodes( // only convert to a function if we have adjusted any URLs if (css !== transformed_css) { - return create_dynamic_css(transformed_css, ['assets', 'base']); + return create_dynamic_string('css', ['assets', 'base'], transformed_css); } + return s(css); }; } diff --git a/packages/kit/src/exports/vite/build/utils.js b/packages/kit/src/exports/vite/build/utils.js index 01bf1d0b60b1..6ddd69fff423 100644 --- a/packages/kit/src/exports/vite/build/utils.js +++ b/packages/kit/src/exports/vite/build/utils.js @@ -132,12 +132,13 @@ export function assets_base(config) { } /** - * @param {string} css The CSS with string interpolations + * @param {string} name * @param {string[]} args + * @param {string} str * @returns {string} */ -export function create_dynamic_css(css, args) { - const escaped_css = s(css).slice(1, -1).replaceAll('`', '\\`'); +export function create_dynamic_string(name, args, str) { + str = s(str).slice(1, -1); const fn_args = args ? args.join(', ') : ''; - return `function css(${fn_args}) { return \`${escaped_css}\`; }`; + return `function ${name}(${fn_args}) { return \`${str}\`; }`; } diff --git a/packages/kit/src/exports/vite/build/utils.spec.js b/packages/kit/src/exports/vite/build/utils.spec.js index 872fd7010c96..f55789006057 100644 --- a/packages/kit/src/exports/vite/build/utils.spec.js +++ b/packages/kit/src/exports/vite/build/utils.spec.js @@ -1,27 +1,15 @@ -import { assert, describe, test } from 'vitest'; -import { create_dynamic_css } from './utils.js'; +import { assert, test } from 'vitest'; +import { create_dynamic_string } from './utils.js'; -describe('create_dynamic_css', () => { - test.each([ - { - name: 'escapes backticks', - input: "div:after { content: '`'; }", - expected: "function css() { return `div:after { content: '\\`'; }`; }" - }, - { - name: 'handles backslashes', - input: "div:after { content: '\\s'; }", - expected: "function css() { return `div:after { content: '\\\\s'; }`; }" - } - ])('$name', ({ input, expected }) => { - const code = create_dynamic_css(input, []); - assert.equal(code, expected); +test('create_dynamic_string escapes backslashes', () => { + const input = "div:after { content: '\\s'; }"; + const code = create_dynamic_string('css', [], input); + assert.equal(code, "function css() { return `div:after { content: '\\\\s'; }`; }"); - let css; - assert.doesNotThrow(() => { - css = eval(`(${code})()`); - }); - - assert.equal(css, input); + let css; + assert.doesNotThrow(() => { + css = eval(`(${code})()`); }); + + assert.equal(css, input); }); diff --git a/packages/kit/src/utils/css.js b/packages/kit/src/utils/css.js index 4224c96141c0..95d35bd7cf16 100644 --- a/packages/kit/src/utils/css.js +++ b/packages/kit/src/utils/css.js @@ -1,5 +1,6 @@ import MagicString from 'magic-string'; import * as svelte from 'svelte/compiler'; +import { escape_for_interpolation } from './escape.js'; /** @typedef {ReturnType['children']} StyleSheetChildren */ @@ -59,9 +60,9 @@ export function fix_css_urls({ return css; } - // if we're doing string interpolation, we should escape existing $ signs first + // if we're going to do string interpolation, we should escape existing backticks and dollar sign symbols if (paths_assets.includes('$') || base.includes('$')) { - css = css.replaceAll('$', '\\$'); + css = escape_for_interpolation(css); } // safe guard in case of trailing slashes (but this should never happen) diff --git a/packages/kit/src/utils/css.spec.js b/packages/kit/src/utils/css.spec.js index 467cd32e3316..c840187931cd 100644 --- a/packages/kit/src/utils/css.spec.js +++ b/packages/kit/src/utils/css.spec.js @@ -169,15 +169,6 @@ describe('fix_css_urls', () => { css: 'div::before { content: "/*"; } div { background: blue /* url(./image.png) */; }', expected: 'div::before { content: "/*"; } div { background: blue /* url(./image.png) */; }', vite_assets: ['image.png'] - }, - { - name: 'escapes $ signs when interpolating', - css: 'div { background: url(./image.png); }\ndiv:after { content: "${example}"; }', - expected: - 'div { background: url(${assets}/image.png); }\ndiv:after { content: "\\${example}"; }', - vite_assets: ['image.png'], - paths_assets: '${assets}', - base: '${base}' } ])( '$name', diff --git a/packages/kit/src/utils/escape.js b/packages/kit/src/utils/escape.js index a73fd951daf8..b2d0c9a6cfa7 100644 --- a/packages/kit/src/utils/escape.js +++ b/packages/kit/src/utils/escape.js @@ -60,3 +60,12 @@ export function escape_html(str, is_attr) { return escaped_str; } + +/** + * Escapes backticks and dollar signs so that they can be safely used in template literals. + * @param {string} str + * @returns {string} escaped string + */ +export function escape_for_interpolation(str) { + return str.replaceAll('`', '\\`').replaceAll('$', '\\$'); +} diff --git a/packages/kit/src/utils/escape.spec.js b/packages/kit/src/utils/escape.spec.js index 3c4e6a042a86..8967b487de3b 100644 --- a/packages/kit/src/utils/escape.spec.js +++ b/packages/kit/src/utils/escape.spec.js @@ -1,5 +1,5 @@ import { assert, test } from 'vitest'; -import { escape_html } from './escape.js'; +import { escape_for_interpolation, escape_html } from './escape.js'; test('escape_html_attr escapes special attribute characters', () => { assert.equal( @@ -17,3 +17,10 @@ test('escape_html_attr escapes invalid surrogates', () => { assert.equal(escape_html('\ud800\udc00\udc00', true), '\ud800\udc00�'); assert.equal(escape_html('\ud800\ud800\udc00\udc00', true), '�\ud800\udc00�'); }); + +test('escape_for_interpolation escapes both backticks and dollar signs', () => { + assert.equal( + escape_for_interpolation('div:after { content: "` and ${example}`"; }'), + 'div:after { content: "\\` and \\${example}\\`"; }' + ); +}); From 3357db466c8aa6c21eec5eccaf3fe8325da0433f Mon Sep 17 00:00:00 2001 From: Tee Ming Date: Mon, 16 Feb 2026 14:27:25 +0800 Subject: [PATCH 4/7] Apply suggestion from @benmccann Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> --- packages/kit/src/exports/vite/build/utils.spec.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/kit/src/exports/vite/build/utils.spec.js b/packages/kit/src/exports/vite/build/utils.spec.js index f55789006057..ecf0637465e5 100644 --- a/packages/kit/src/exports/vite/build/utils.spec.js +++ b/packages/kit/src/exports/vite/build/utils.spec.js @@ -6,10 +6,7 @@ test('create_dynamic_string escapes backslashes', () => { const code = create_dynamic_string('css', [], input); assert.equal(code, "function css() { return `div:after { content: '\\\\s'; }`; }"); - let css; - assert.doesNotThrow(() => { - css = eval(`(${code})()`); - }); + const css; = eval(`(${code})()`); assert.equal(css, input); }); From eab188a727dd62067cc5a222a58785b81693e7b8 Mon Sep 17 00:00:00 2001 From: Tee Ming Date: Mon, 16 Feb 2026 14:28:25 +0800 Subject: [PATCH 5/7] Apply suggestion from @benmccann Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> --- packages/kit/src/utils/css.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/kit/src/utils/css.js b/packages/kit/src/utils/css.js index 95d35bd7cf16..9553fd38e48e 100644 --- a/packages/kit/src/utils/css.js +++ b/packages/kit/src/utils/css.js @@ -60,10 +60,7 @@ export function fix_css_urls({ return css; } - // if we're going to do string interpolation, we should escape existing backticks and dollar sign symbols - if (paths_assets.includes('$') || base.includes('$')) { - css = escape_for_interpolation(css); - } + css = escape_for_interpolation(css); // safe guard in case of trailing slashes (but this should never happen) if (paths_assets.endsWith('/')) { From 372819229b1f21f562182a025bfed4f490159e80 Mon Sep 17 00:00:00 2001 From: Tee Ming Date: Mon, 16 Feb 2026 14:29:02 +0800 Subject: [PATCH 6/7] Apply suggestion from @teemingc --- packages/kit/src/exports/vite/build/utils.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/exports/vite/build/utils.spec.js b/packages/kit/src/exports/vite/build/utils.spec.js index ecf0637465e5..c330004acf39 100644 --- a/packages/kit/src/exports/vite/build/utils.spec.js +++ b/packages/kit/src/exports/vite/build/utils.spec.js @@ -6,7 +6,7 @@ test('create_dynamic_string escapes backslashes', () => { const code = create_dynamic_string('css', [], input); assert.equal(code, "function css() { return `div:after { content: '\\\\s'; }`; }"); - const css; = eval(`(${code})()`); + const css = eval(`(${code})()`); assert.equal(css, input); }); From d2a411adc1fc567b18a58f0c62c1dfa97d10ef21 Mon Sep 17 00:00:00 2001 From: Tee Ming Date: Tue, 17 Feb 2026 00:25:12 +0800 Subject: [PATCH 7/7] rename and document --- .../kit/src/exports/vite/build/build_server.js | 4 ++-- packages/kit/src/exports/vite/build/utils.js | 17 ++++++++++------- .../kit/src/exports/vite/build/utils.spec.js | 4 ++-- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/kit/src/exports/vite/build/build_server.js b/packages/kit/src/exports/vite/build/build_server.js index caa505f9f540..c952be9d0022 100644 --- a/packages/kit/src/exports/vite/build/build_server.js +++ b/packages/kit/src/exports/vite/build/build_server.js @@ -1,6 +1,6 @@ import fs from 'node:fs'; import { mkdirp } from '../../../utils/filesystem.js'; -import { create_dynamic_string, filter_fonts, find_deps, resolve_symlinks } from './utils.js'; +import { create_function_as_string, filter_fonts, find_deps, resolve_symlinks } from './utils.js'; import { s } from '../../../utils/misc.js'; import { normalizePath } from 'vite'; import { basename } from 'node:path'; @@ -78,7 +78,7 @@ export function build_server_nodes( // only convert to a function if we have adjusted any URLs if (css !== transformed_css) { - return create_dynamic_string('css', ['assets', 'base'], transformed_css); + return create_function_as_string('css', ['assets', 'base'], transformed_css); } return s(css); diff --git a/packages/kit/src/exports/vite/build/utils.js b/packages/kit/src/exports/vite/build/utils.js index 6ddd69fff423..8da2e7020c6a 100644 --- a/packages/kit/src/exports/vite/build/utils.js +++ b/packages/kit/src/exports/vite/build/utils.js @@ -132,13 +132,16 @@ export function assets_base(config) { } /** - * @param {string} name - * @param {string[]} args - * @param {string} str - * @returns {string} + * Writes a function with arguments used by a template literal. + * This helps us store strings in a module and inject values at runtime. + * @param {string} name The name of the function + * @param {string[]} placeholder_names The names of the placeholders in the string + * @param {string} str A string with placeholders such as "Hello ${arg0}". + * It must have backticks and dollar signs escaped. + * @returns {string} The function written as a string */ -export function create_dynamic_string(name, args, str) { +export function create_function_as_string(name, placeholder_names, str) { str = s(str).slice(1, -1); - const fn_args = args ? args.join(', ') : ''; - return `function ${name}(${fn_args}) { return \`${str}\`; }`; + const args = placeholder_names ? placeholder_names.join(', ') : ''; + return `function ${name}(${args}) { return \`${str}\`; }`; } diff --git a/packages/kit/src/exports/vite/build/utils.spec.js b/packages/kit/src/exports/vite/build/utils.spec.js index c330004acf39..26ce1b2e9bcd 100644 --- a/packages/kit/src/exports/vite/build/utils.spec.js +++ b/packages/kit/src/exports/vite/build/utils.spec.js @@ -1,9 +1,9 @@ import { assert, test } from 'vitest'; -import { create_dynamic_string } from './utils.js'; +import { create_function_as_string } from './utils.js'; test('create_dynamic_string escapes backslashes', () => { const input = "div:after { content: '\\s'; }"; - const code = create_dynamic_string('css', [], input); + const code = create_function_as_string('css', [], input); assert.equal(code, "function css() { return `div:after { content: '\\\\s'; }`; }"); const css = eval(`(${code})()`);