fix(core): preserve images with commas in srcset URLs (#6192)

This commit is contained in:
Akshar Patel
2026-10-24 08:16:02 +00:00
committed by GitHub
parent 4153460856
commit 73dda7191c
3 changed files with 109 additions and 32 deletions
+6
View File
@@ -129,6 +129,12 @@ const bundled = await bundleToSingleHtml("./project", {
});
```
Bundling preserves data URLs and commas inside image `srcset` URLs on `<img>`
and `<source>` elements. Eligible local candidates are inlined by default; with
`inlineAssets: false`, they stay as paths relative to the project root, including
when the entry file is in a subdirectory. Width and density descriptors are
preserved.
The compiler also exposes lower-level timing helpers such as
`compileTimingAttrs()`, `injectDurations()`, and `extractResolvedMedia()`.
@@ -2096,6 +2096,66 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain(`url('${inlinedAs("image/png", "fake-sprite")}?v=2#section')`);
});
it.each([true, false])(
"preserves srcset URL commas with inlineAssets %s",
async (inlineAssets) => {
const srcset = `${inlinedAs("image/svg+xml", '<svg xmlns="http://www.w3.org/2000/svg"/>')} 1x, https://cdn.example/hero,blue.png 2x`;
const dir = makeTempProject({
"index.html": `<html><body><div data-composition-id="root" data-width="320" data-height="180" data-duration="1" data-no-timeline><img id="photo" srcset="${srcset}"></div></body></html>`,
});
try {
const bundled = await bundleToSingleHtml(dir, { inlineAssets });
const { document } = parseHTML(bundled);
expect(document.getElementById("photo")?.getAttribute("srcset")).toBe(srcset);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
it("inlines a comma-named srcset image beside an existing data URL", async () => {
const dataUrl = inlinedAs("image/svg+xml", '<svg xmlns="http://www.w3.org/2000/svg"/>');
const dir = makeTempProject({
"index.html": `<html><body><div data-composition-id="root" data-width="320" data-height="180" data-duration="1" data-no-timeline><img id="photo" srcset="${dataUrl} 1x, hero,blue.png 2x"></div></body></html>`,
"hero,blue.png": "blue-image-bytes",
});
try {
const bundled = await bundleToSingleHtml(dir);
const { document } = parseHTML(bundled);
expect(document.getElementById("photo")?.getAttribute("srcset")).toBe(
`${dataUrl} 1x, ${inlinedAs("image/png", "blue-image-bytes")} 2x`,
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it.each([true, false])(
"rebases complete picture srcset URLs with inlineAssets %s",
async (inlineAssets) => {
const dataUrl = inlinedAs("image/svg+xml", '<svg xmlns="http://www.w3.org/2000/svg"/>');
const dir = makeTempProject({
"scenes/scene.html": `<html><body><div data-composition-id="root" data-width="320" data-height="180" data-duration="1" data-no-timeline><picture><source id="photo" srcset="${dataUrl} 160w, hero,blue.png 320w"><img src="hero,blue.png"></picture></div></body></html>`,
"scenes/hero,blue.png": "blue-image-bytes",
});
try {
const bundled = await bundleToSingleHtml(dir, {
entryFile: "scenes/scene.html",
inlineAssets,
});
const { document } = parseHTML(bundled);
const image = inlineAssets
? inlinedAs("image/png", "blue-image-bytes")
: "scenes/hero%2Cblue.png";
expect(document.getElementById("photo")?.getAttribute("srcset")).toBe(
`${dataUrl} 160w, ${image} 320w`,
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
it("inlines fonts, images and scripts so no relative asset reference survives", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
+43 -32
View File
@@ -181,20 +181,37 @@ function rebaseRelativePath(urlValue: string, fromDir: string, toDir: string): s
return appendSuffixToUrl(rebased, suffix);
}
function rebaseSrcsetPaths(srcsetValue: string, fromDir: string, toDir: string): string {
if (!srcsetValue) return srcsetValue;
return srcsetValue
.split(",")
.map((rawCandidate) => {
const candidate = rawCandidate.trim();
if (!candidate) return candidate;
const parts = candidate.split(/\s+/);
const first = parts[0] ?? "";
if (parts.length === 0 || !isRelativeUrl(first)) return candidate;
parts[0] = rebaseRelativePath(first, fromDir, toDir);
return parts.join(" ");
})
.join(", ");
function rewriteSrcsetUrls(srcset: string, rewriteUrl: (url: string) => string): string {
const parts: string[] = [];
let position = 0;
let written = 0;
// Preserve URL commas per https://html.spec.whatwg.org/multipage/images.html#parse-a-srcset-attribute
while (position < srcset.length) {
while (position < srcset.length && /[ \t\n\r\f,]/.test(srcset.charAt(position))) position++;
const start = position;
while (position < srcset.length && !/[ \t\n\r\f]/.test(srcset.charAt(position))) position++;
let end = position;
while (end > start && srcset.charAt(end - 1) === ",") end--;
if (end > start) {
const url = srcset.slice(start, end);
const rewritten = rewriteUrl(url);
if (rewritten !== url) {
parts.push(srcset.slice(written, start), rewritten);
written = end;
}
}
if (end < position) continue;
let inParens = false;
while (position < srcset.length) {
const char = srcset.charAt(position++);
if (char === "(") inParens = true;
else if (char === ")") inParens = false;
else if (char === "," && !inParens) break;
}
}
return parts.length > 0 ? parts.join("") + srcset.slice(written) : srcset;
}
function rebaseColorGradingLutPath(value: string, fromDir: string, toDir: string): string {
@@ -244,7 +261,13 @@ function rebaseEntryAuthoredAssetPaths(
}
for (const el of [...document.querySelectorAll("[srcset]")]) {
const srcset = el.getAttribute("srcset");
if (srcset) el.setAttribute("srcset", rebaseSrcsetPaths(srcset, sourceDir, projectDir));
if (srcset)
el.setAttribute(
"srcset",
rewriteSrcsetUrls(srcset, (url) =>
isRelativeUrl(url) ? rebaseRelativePath(url, sourceDir, projectDir) : url,
),
);
}
for (const el of [...document.querySelectorAll(`[${HF_COLOR_GRADING_ATTR}]`)]) {
const value = el.getAttribute(HF_COLOR_GRADING_ATTR);
@@ -489,22 +512,6 @@ function rewriteColorGradingLutWithInlinedAssets(value: string, inlineAsset: Ass
return JSON.stringify(parsed);
}
function rewriteSrcsetWithInlinedAssets(srcsetValue: string, inlineAsset: AssetInliner): string {
if (!srcsetValue) return srcsetValue;
return srcsetValue
.split(",")
.map((rawCandidate) => {
const candidate = rawCandidate.trim();
if (!candidate) return candidate;
const parts = candidate.split(/\s+/);
if (parts.length === 0) return candidate;
const maybeInlined = inlineAsset(parts[0] ?? "");
if (maybeInlined) parts[0] = maybeInlined;
return parts.join(" ");
})
.join(", ");
}
function rewriteCssUrlsWithInlinedAssets(cssText: string, inlineAsset: AssetInliner): string {
if (!cssText) return cssText;
return cssText.replace(
@@ -1467,7 +1474,11 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
}
for (const el of [...document.querySelectorAll("[srcset]")]) {
const srcset = el.getAttribute("srcset");
if (srcset) el.setAttribute("srcset", rewriteSrcsetWithInlinedAssets(srcset, inlineAsset));
if (srcset)
el.setAttribute(
"srcset",
rewriteSrcsetUrls(srcset, (url) => inlineAsset(url) ?? url),
);
}
// Before inlining, so postcss reads paths not font bytes.
const liveStyles = [...document.querySelectorAll("style")].filter(isAlwaysAppliedStyle);