/** * Build a CSS `url()` value that survives any URL the browser would accept. * * An unquoted `url(...)` token is invalid the moment the URL contains a * parenthesis, quote, or whitespace. That happens for real inputs: * `encodeURIComponent` leaves `(` and `)` alone, so an SVG data URI with * `fill="url(#g)"` produces `url(data:...url(%23g)...)`, and uploaded files * named like `swatch (1).png` produce the same shape. Chromium drops the * whole declaration, leaving the swatch blank. * * Wrapping the URL in a double-quoted CSS string makes those characters * literal. Inside the string only `"`, `\`, and control characters (which a * CSS string may not contain raw) need escaping, and CSS hex escapes handle * them without re-encoding the URL. CSS parsing still applies its standard * normalization (notably replacing a NUL with U+FFFD). The security contract is * unchanged: the string cannot terminate early, and the resulting declaration * is always exactly one `url("...")` token. */ export function escapeCssString(value: string): string { // eslint-disable-next-line no-control-regex return value.replace(/["\\\u0000-\u001f\u007f]/g, (char) => { if (char === '"') return '\\"'; if (char === "\\") return "\\\\"; // Hex escape with a trailing space so following hex digits are not // absorbed into the escape. return `\\${char.charCodeAt(0).toString(16)} `; }); } export function cssUrl(url: string | null | undefined): string { return `url("${escapeCssString(typeof url === "string" ? url : "")}")`; }