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);