Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/packages/core.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.

Expand Down
60 changes: 60 additions & 0 deletions packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", '<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>
Expand Down
75 changes: 43 additions & 32 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
Loading