import { describe, expect, it } from "vitest"; import { cssUrl, escapeCssString } from "./cssUrl"; /** * A CSS string token is: `"` then any run of (non-special char | `\` + any * non-newline char) then `"`. If the output matches this exactly, wrapped in * `url(` ... `)`, then the browser tokenizes it as a single url() value and * the input cannot terminate the string or leak into the declaration. */ const SINGLE_URL_TOKEN = // eslint-disable-next-line no-control-regex /^url\("(?:[^"\\\u0000-\u001f\u007f]|\\[^\n\r\f])*"\)$/; /** Reverse the CSS string escapes the way a CSS tokenizer would. */ function unescapeCssString(value: string): string { return value.replace( /\\([0-9a-fA-F]{1,6}) ?|\\(.)/gs, (_match, hex: string | undefined, char: string | undefined) => hex ? String.fromCodePoint(parseInt(hex, 16) || 0xfffd) : (char as string), ); } function urlInside(value: string): string { const match = /^url\("(.*)"\)$/s.exec(value); if (!match) throw new Error(`Not a quoted url(): ${value}`); return unescapeCssString(match[1]); } const svgWithGradientFill = ''; const svgDataUri = `data:image/svg+xml,${encodeURIComponent(svgWithGradientFill)}`; describe("cssUrl", () => { it("keeps an SVG data URI containing url(#g) as one quoted url() token", () => { // encodeURIComponent leaves ( and ) alone, which is what broke the // unquoted form in Chromium. expect(svgDataUri).toContain("url(%23g)"); const value = cssUrl(svgDataUri); expect(value).toMatch(SINGLE_URL_TOKEN); expect(value).toBe(`url("${svgDataUri}")`); expect(urlInside(value)).toBe(svgDataUri); }); it("keeps an https URL with parentheses in the filename intact", () => { const url = "https://shop.example/wp-content/uploads/2026/09/swatch (1).png"; const value = cssUrl(url); expect(value).toMatch(SINGLE_URL_TOKEN); expect(urlInside(value)).toBe(url); // No re-encoding: the browser must resolve exactly the stored URL. expect(value).toContain("swatch (1).png"); }); it("escapes double quotes and backslashes so the string cannot be terminated early", () => { const url = 'https://shop.example/a"b\\c.png") ; background: red; x: url("'; const value = cssUrl(url); expect(value).toMatch(SINGLE_URL_TOKEN); expect(value).toBe( 'url("https://shop.example/a\\"b\\\\c.png\\") ; background: red; x: url(\\"")', ); expect(urlInside(value)).toBe(url); }); it("hex-escapes control characters, which a CSS string may not contain raw", () => { const url = "https://shop.example/a\nb\tc\u0000d\u007fe.png"; const value = cssUrl(url); expect(value).toMatch(SINGLE_URL_TOKEN); expect(value).not.toMatch(/[\n\t\u0000\u007f]/); expect(value).toBe( 'url("https://shop.example/a\\a b\\9 c\\0 d\\7f e.png")', ); // Trailing space terminates the escape so the following char is not eaten. // CSS replaces escaped NUL with U+FFFD rather than preserving NUL. expect(urlInside(value)).toBe(url.replace(/\u0000/g, "\ufffd")); }); it("leaves ordinary URL characters untouched", () => { const url = "https://cdn.example/p/x.png?size=64&v=2#frag~!*'();:@&=+$,/?%[]"; expect(escapeCssString(url)).toBe(url); expect(cssUrl(url)).toBe(`url("${url}")`); }); it("renders a missing URL as an empty quoted url()", () => { expect(cssUrl(undefined)).toBe('url("")'); expect(cssUrl(null)).toBe('url("")'); }); });