Skip to content

Commit a871cab

Browse files
committed
fix(core): preserve CSS URL identity and escape rebased suffixes
1 parent 01b8f06 commit a871cab

4 files changed

Lines changed: 70 additions & 18 deletions

File tree

‎packages/core/src/compiler/htmlBundler.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1858,6 +1858,52 @@ describe("bundleToSingleHtml", () => {
18581858
expect(styleText(linked)).toContain('url("css/icon1.png")');
18591859
});
18601860

1861+
it("preserves escaped leading spaces as part of linked CSS asset identity", async () => {
1862+
const dir = makeTempProject({
1863+
"index.html": `<link rel="stylesheet" href="css/theme.css"><div data-composition-id="root" data-width="320" data-height="180"></div>`,
1864+
"css/theme.css": String.raw`.bg { background: url(\ image.png); }`,
1865+
"css/ image.png": "leading-space-image",
1866+
"css/image.png": "different-image",
1867+
});
1868+
1869+
const linked = await bundleToSingleHtml(dir, { inlineAssets: false });
1870+
expect(styleText(linked)).toContain("url(css/%20image.png)");
1871+
const inlined = await bundleToSingleHtml(dir);
1872+
expect(styleText(inlined)).toContain(inlinedAs("image/png", "leading-space-image"));
1873+
expect(styleText(inlined)).not.toContain(inlinedAs("image/png", "different-image"));
1874+
});
1875+
1876+
it.each([
1877+
["", String.raw`?v=\ 1`, String.raw`?v=\0000201`],
1878+
['"', String.raw`?v=\22 1`, String.raw`?v=\0000221`],
1879+
['"', String.raw`?v=\22`, String.raw`?v=\000022`],
1880+
['"', String.raw`?v=\3c /style\3e 1`, String.raw`?v=\00003c/style\00003e1`],
1881+
])("escapes decoded CSS URL suffixes with delimiter %j", async (quote, suffix, escaped) => {
1882+
const dir = makeTempProject({
1883+
"index.html": `<link rel="stylesheet" href="css/theme.css"><div data-composition-id="root" data-width="320" data-height="180"></div>`,
1884+
"css/theme.css": `.bg { background: url(${quote}icon.png${suffix}${quote}); }`,
1885+
"css/icon.png": "suffix-image",
1886+
});
1887+
1888+
const linked = await bundleToSingleHtml(dir, { inlineAssets: false });
1889+
expect(styleText(linked)).toContain(`url(${quote}css/icon.png${escaped}${quote})`);
1890+
const inlined = await bundleToSingleHtml(dir);
1891+
expect(styleText(inlined)).toContain(inlinedAs("image/png", "suffix-image"));
1892+
});
1893+
1894+
it("normalizes CSS nulls before resolving linked stylesheet assets", async () => {
1895+
const dir = makeTempProject({
1896+
"index.html": `<link rel="stylesheet" href="css/theme.css"><div data-composition-id="root" data-width="320" data-height="180"></div>`,
1897+
"css/theme.css": ".bg { background: url(\0.png); }",
1898+
"css/\uFFFD.png": "replacement-character-image",
1899+
});
1900+
1901+
const linked = await bundleToSingleHtml(dir, { inlineAssets: false });
1902+
expect(styleText(linked)).toContain("url(css/%EF%BF%BD.png)");
1903+
const inlined = await bundleToSingleHtml(dir);
1904+
expect(styleText(inlined)).toContain(inlinedAs("image/png", "replacement-character-image"));
1905+
});
1906+
18611907
it("rebases url() paths with ../ traversal in nested @import", async () => {
18621908
const dir = makeTempProject({
18631909
"index.html": `<!doctype html>

‎packages/core/src/compiler/htmlBundler.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,18 @@ function rebaseCssUrls(css: string, cssFileDir: string, projectDir: string): str
145145
const resolvedDir = resolve(cssFileDir);
146146
if (resolvedDir === resolvedRoot) return css;
147147
return css.replace(CSS_URL_RE, (full, quote: string, urlValue: string) => {
148-
const decoded = decodeCssEscapes(urlValue).trim();
148+
const decoded = decodeCssEscapes(urlValue);
149149
if (!decoded || !isRelativeUrl(decoded)) return full;
150150
const { basePath, suffix } = splitUrlSuffix(decoded);
151151
if (!basePath) return full;
152152
const absolutePath = resolve(resolvedDir, decodeWellFormedEscapes(basePath));
153153
const rebased = encodeUrlPath(relative(resolvedRoot, absolutePath).split(sep).join("/"));
154154
if (rebased === basePath) return full;
155-
return `url(${quote || ""}${rebased}${suffix}${quote || ""})`;
155+
const escapedSuffix = suffix.replace(
156+
/[\x00-\x20\x7f"'()\\<>]/g,
157+
(char) => `\\${char.charCodeAt(0).toString(16).padStart(6, "0")}`,
158+
);
159+
return `url(${quote || ""}${rebased}${escapedSuffix}${quote || ""})`;
156160
});
157161
}
158162

‎packages/parsers/src/utils/urlPath.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ describe("URL filename identity", () => {
1313
it("removes URL suffixes before decoding encoded filename punctuation exactly once", () => {
1414
expect(decodedUrlPath("a%2520%3F%23.png?cache=1#view")).toBe("a%20?#.png");
1515
});
16-
it("replaces invalid CSS escaped code points before URL encoding", () => {
17-
for (const escaped of [String.raw`\0`, String.raw`\d800`, String.raw`\110000`]) {
16+
it("normalizes CSS nulls and invalid escaped code points before URL encoding", () => {
17+
for (const escaped of ["\0", String.raw`\0`, String.raw`\d800`, String.raw`\110000`]) {
1818
expect(decodeCssEscapes(escaped)).toBe("\uFFFD");
1919
}
2020
});

‎packages/parsers/src/utils/urlPath.ts‎

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,20 @@ export function encodeUrlPath(path: string): string {
3535
}
3636

3737
export function decodeCssEscapes(text: string): string {
38-
return text.replace(
39-
/\\(?:([0-9a-f]{1,6})(?:\r\n|[ \t\r\n\f])?|(\r\n|[\n\r\f])|([\s\S]))/gi,
40-
(
41-
_escaped,
42-
hex: string | undefined,
43-
lineBreak: string | undefined,
44-
char: string | undefined,
45-
) => {
46-
if (hex === undefined) return lineBreak === undefined ? char! : "";
47-
const code = Number.parseInt(hex, 16);
48-
if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return "\uFFFD";
49-
return String.fromCodePoint(code);
50-
},
51-
);
38+
return text
39+
.replace(/\0/g, "\uFFFD")
40+
.replace(
41+
/\\(?:([0-9a-f]{1,6})(?:\r\n|[ \t\r\n\f])?|(\r\n|[\n\r\f])|([\s\S]))/gi,
42+
(
43+
_escaped,
44+
hex: string | undefined,
45+
lineBreak: string | undefined,
46+
char: string | undefined,
47+
) => {
48+
if (hex === undefined) return lineBreak === undefined ? char! : "";
49+
const code = Number.parseInt(hex, 16);
50+
if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return "\uFFFD";
51+
return String.fromCodePoint(code);
52+
},
53+
);
5254
}

0 commit comments

Comments
 (0)