From c9ec34aa319b373262c36f4c27d2726beb32e77c Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Sat, 24 Oct 2026 00:44:44 -0400 Subject: [PATCH] fix(core): preserve images with commas in srcset URLs --- docs/packages/core.mdx | 6 ++ .../core/src/compiler/htmlBundler.test.ts | 60 +++++++++++++++ packages/core/src/compiler/htmlBundler.ts | 75 +++++++++++-------- 3 files changed, 109 insertions(+), 32 deletions(-) diff --git a/docs/packages/core.mdx b/docs/packages/core.mdx index e2cd1a8237b..8b88225bad0 100644 --- a/docs/packages/core.mdx +++ b/docs/packages/core.mdx @@ -129,6 +129,12 @@ const bundled = await bundleToSingleHtml("./project", { }); ``` +Bundling preserves data URLs and commas inside image `srcset` URLs on `` +and `` 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()`. diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 8978a744947..8e5ab571906 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -2040,6 +2040,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", '')} 1x, https://cdn.example/hero,blue.png 2x`; + const dir = makeTempProject({ + "index.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", ''); + const dir = makeTempProject({ + "index.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", ''); + const dir = makeTempProject({ + "scenes/scene.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": ` diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 17b5530d9d7..618a11c87ad 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -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); @@ -471,22 +494,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( @@ -1449,7 +1456,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);