mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
fix(studio): resolve project-root-relative asset URLs in preview iframe (#1698)
## What Studio preview now resolves `<video src="../../assets/x.mp4">` (and the same shape for `<img>`, `<audio>`, inline `style` `url()`, and `<style>` CSS `url()`) against the sub-composition's URL — matching what the server-side bundler already does for the render path. ## Why Authored compositions live at `compositions/frames/*.html` and reference project-root assets either as plain `assets/x.mp4` (already correct because the main document's `<base href>` points at the project preview root) or as `../../assets/x.mp4` (the explicit project-root-relative form). The server-side `inlineSubCompositions` flattens sub-comps into `index.html` and rewrites the `../`-form against the sub-comp's source path so it resolves against the project root in the baked render. The browser-side runtime that mounts external sub-compositions via `fetch` did no such rewriting. So `<video src="../../assets/x.mp4">` authored inside a `compositions/frames/scene.html` resolved against the main document's base href, climbed above the project root, and 404'd in Studio preview — even though the same path rendered correctly in the final video. An OSS user (Miao Yang) hit this in a real project. ## How Added `rewriteSubCompositionAssetPaths` to the runtime `compositionLoader`. After parsing the fetched sub-composition HTML and before extracting any nodes, walk the parsed document and rewrite the same surface the server-side path touches: - `[src]` and `[href]` attributes on every element - `[style]` attribute `url(...)` references - `<style>` element CSS `url(...)` references The rewrite mirrors the producer's semantics exactly: only values that start with `../` (or are literal `..`) are rewritten — against the sub-composition's URL via `new URL(value, compositionUrl)`. Absolute URLs, root-relative paths, `data:`, hash refs, and plain `assets/x.mp4` are left untouched. **Plain relative paths must not be rewritten** because the main document's `<base href>` already covers them; rewriting would double-prefix the URL. The walk recurses into `<template>` content because authored compositions typically wrap their rendered body in a `<template>` and `querySelectorAll` does not enter template content (it lives in a detached `DocumentFragment`). ## Test plan - [x] Unit tests added (6 new tests in `compositionLoader.test.ts`): rewrites `../`-traversing src on template-wrapped sub-comps; leaves plain relative paths untouched (no double-prefix); leaves absolute / data / hash / root-relative URLs untouched; rewrites CSS `url()` in `<style>` blocks and inline `style` attributes; rewrites for non-template (full-HTML-doc) sub-comps. - [x] Full core test suite green (2065 tests). - [x] Full studio test suite green (1148 tests). - [x] Manual verification with the reporter's actual project: before the fix one `<video>` with a `../../assets/...` src returned `MEDIA_ELEMENT_ERROR: Format error`; after the fix all 7 `<video>` elements load (`readyState=4`, correct `currentSrc`). The 6 plain `assets/...` paths are *unchanged* (no double-prefix) and continue to resolve via `<base href>` as before. - [x] `bun run lint`, `bun run format:check`, `bun run typecheck`, `fallow audit` all green. Reported by Miao Yang. — Jerrai (https://claude.com/claude-code)
This commit is contained in:
@@ -775,7 +775,9 @@ describe("composition rules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid_capture_path", () => {
|
||||
describe("invalid_parent_traversal_in_asset_path", () => {
|
||||
const RULE_CODE = "invalid_parent_traversal_in_asset_path";
|
||||
|
||||
it("errors when an <img> src uses ../capture/", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="x">
|
||||
@@ -785,27 +787,85 @@ describe("composition rules", () => {
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === "invalid_capture_path");
|
||||
const finding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("../capture/");
|
||||
});
|
||||
|
||||
it("errors when a CSS url() uses ../capture/ (counts all occurrences)", async () => {
|
||||
it("errors when a <video> src uses ../assets/ (HF#1698 shape)", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="x">
|
||||
<video src="../assets/clip.mp4" muted></video>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("../assets/");
|
||||
});
|
||||
|
||||
it("errors when a <video> src uses ../../assets/ from a nested compositions/frames/ file", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="x">
|
||||
<video src="../../assets/clip.mp4" muted></video>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/frames/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("../../assets/");
|
||||
});
|
||||
|
||||
it("errors when a <link> href uses ../fonts/", async () => {
|
||||
const html = `<html><head>
|
||||
<link rel="stylesheet" href="../fonts/brand.css">
|
||||
</head><body>
|
||||
<div data-composition-id="x"></div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("../fonts/");
|
||||
});
|
||||
|
||||
it("errors when a CSS url() uses ../assets/ in a <style> block (counts all occurrences)", async () => {
|
||||
const html = `<html><body>
|
||||
<style>
|
||||
@font-face { font-family: 'Brand'; src: url('../capture/assets/fonts/Brand.woff2'); }
|
||||
.hero { background-image: url('../capture/assets/hero.png'); }
|
||||
@font-face { font-family: 'Brand'; src: url('../fonts/Brand.woff2'); }
|
||||
.hero { background-image: url('../assets/hero.png'); }
|
||||
</style>
|
||||
<div data-composition-id="x"></div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === "invalid_capture_path");
|
||||
const finding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("2 asset path(s)");
|
||||
});
|
||||
|
||||
it("errors when an inline style url() uses ../assets/", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="x">
|
||||
<div style="background-image: url('../assets/hero.png');"></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("../assets/");
|
||||
});
|
||||
|
||||
it("does not flag root-relative capture/ paths", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="x">
|
||||
@@ -816,9 +876,119 @@ describe("composition rules", () => {
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === "invalid_capture_path");
|
||||
const finding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag plain relative asset paths (e.g. assets/x.mp4)", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="x">
|
||||
<video src="assets/x.mp4" muted></video>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag absolute URLs", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="x">
|
||||
<img src="https://example.com/foo.png">
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<style>.hero { background-image: url('https://example.com/hero.png'); }</style>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag data: URIs", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="x">
|
||||
<img src="data:image/png;base64,iVBORw0KGgo=">
|
||||
<style>.hero { background-image: url('data:image/svg+xml,%3Csvg/%3E'); }</style>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag root-relative absolute paths (e.g. /absolute/path.mp4)", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="x">
|
||||
<video src="/absolute/path.mp4" muted></video>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not flag hash refs (e.g. href="#anchor")', async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="x">
|
||||
<a href="#section">jump</a>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag registry source block files", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="x">
|
||||
<img src="../assets/should-be-ignored.png">
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/registry/blocks/data-chart/data-chart.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag installed registry blocks", async () => {
|
||||
const html = `<!-- hyperframes-registry-item: data-chart -->\n<html><body>
|
||||
<div data-composition-id="x">
|
||||
<img src="../assets/should-be-ignored.png">
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/data-chart.html",
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not regress under the old code (invalid_capture_path) — the rule was renamed", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="x">
|
||||
<img src="../capture/assets/logo.svg" alt="logo">
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath: "/project/compositions/scene.html",
|
||||
});
|
||||
// The old code is gone; the new code subsumes it.
|
||||
const oldFinding = result.findings.find((f) => f.code === "invalid_capture_path");
|
||||
expect(oldFinding).toBeUndefined();
|
||||
const newFinding = result.findings.find((f) => f.code === RULE_CODE);
|
||||
expect(newFinding).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subcomposition_blanks_before_host", () => {
|
||||
|
||||
@@ -37,6 +37,22 @@ function isCompositionRootOrMount(rawTag: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// Asset references inside CSS `url(...)`/`url("...")`/`url('...')` functions.
|
||||
// Returns the inner path without quotes; comments are stripped first so
|
||||
// `/* url(foo) */` is ignored. Bare `url()` and `data:` are excluded by the
|
||||
// rules that consume this — the helper just yields raw URL values.
|
||||
function extractCssUrlReferences(css: string): string[] {
|
||||
const out: string[] = [];
|
||||
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const urlPattern = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = urlPattern.exec(noComments)) !== null) {
|
||||
const raw = (m[2] ?? "").trim();
|
||||
if (raw) out.push(raw);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Top-level CSS selectors (comma-split) in a stylesheet, skipping at-rule headers
|
||||
// (@media/@keyframes/...) and keyframe stops. Heuristic — the lint layer has no
|
||||
// full CSS parser, and rules elsewhere in this file scan CSS the same way.
|
||||
@@ -77,22 +93,71 @@ function rootClassStyledSelectors(styles: ExtractedBlock[], rootClasses: string[
|
||||
}
|
||||
|
||||
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// invalid_capture_path — catches ../capture/ in src/href attributes and scripts.
|
||||
// Sub-compositions live in compositions/ but are served relative to the project
|
||||
// root, so all asset paths must be root-relative ("capture/...").
|
||||
// Using "../capture/..." works on disk but breaks in Studio and renders.
|
||||
({ rawSource, options }) => {
|
||||
// invalid_parent_traversal_in_asset_path — catches `../` traversal in src,
|
||||
// href, inline-style url(), and <style> url() asset references on
|
||||
// compositions. Sub-compositions live under compositions/ but are served
|
||||
// with the project root as their base URL, so any `../`-traversing path
|
||||
// climbs above the project root and 404s in Studio preview. Renders
|
||||
// tolerate it because the server-side bundler rewrites `../foo` against
|
||||
// each sub-composition's source path; the runtime now mirrors that fallback
|
||||
// (see rewriteSubCompositionAssetPaths in runtime/compositionLoader.ts), but
|
||||
// the authoring-time signal is still wrong — flag it at lint time so the
|
||||
// baked path is plain root-relative and matches what the bundler emits.
|
||||
//
|
||||
// Mirrors the runtime fallback's surface: `[src]` / `[href]` attribute
|
||||
// values, `[style]` inline url(), and `<style>` block url() references.
|
||||
// Skips absolute URLs (http(s)://, //, data:, /-prefixed root-relative),
|
||||
// hash anchors, and plain relative paths (`assets/x.mp4`) — only `../`
|
||||
// traversal is flagged. Subsumes the older `../capture/`-specific rule.
|
||||
// fallow-ignore-next-line complexity
|
||||
({ tags, styles, rawSource, options }) => {
|
||||
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
||||
// Only flag in sub-compositions and root compositions — not in registry blocks
|
||||
const matches = rawSource.match(/\.\.\/capture\//g);
|
||||
if (!matches || matches.length === 0) return [];
|
||||
|
||||
const offenders: string[] = [];
|
||||
const collect = (value: string | null) => {
|
||||
if (!value) return;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("../") && trimmed !== "..") return;
|
||||
offenders.push(trimmed);
|
||||
};
|
||||
|
||||
for (const tag of tags) {
|
||||
collect(readAttr(tag.raw, "src"));
|
||||
collect(readAttr(tag.raw, "href"));
|
||||
// Use readJsonAttr for `style` — inline url('...') values contain the
|
||||
// opposite quote, which readAttr's [^"']+ class would truncate.
|
||||
const styleAttr = readJsonAttr(tag.raw, "style");
|
||||
if (styleAttr) {
|
||||
for (const url of extractCssUrlReferences(styleAttr)) collect(url);
|
||||
}
|
||||
}
|
||||
for (const style of styles) {
|
||||
for (const url of extractCssUrlReferences(style.content)) collect(url);
|
||||
}
|
||||
|
||||
if (offenders.length === 0) return [];
|
||||
|
||||
// Group counts by leading path token (e.g. ../capture/, ../assets/, ../../assets/)
|
||||
// so the message names the offending prefixes instead of a bare count.
|
||||
const prefixCounts = new Map<string, number>();
|
||||
for (const path of offenders) {
|
||||
const prefix = path.match(/^(?:\.\.\/)+[^/]+\//)?.[0] ?? path;
|
||||
prefixCounts.set(prefix, (prefixCounts.get(prefix) ?? 0) + 1);
|
||||
}
|
||||
const prefixSummary = Array.from(prefixCounts.entries())
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([prefix, count]) => (count > 1 ? `${prefix} (${count})` : prefix))
|
||||
.join(", ");
|
||||
|
||||
return [
|
||||
{
|
||||
code: "invalid_capture_path",
|
||||
code: "invalid_parent_traversal_in_asset_path",
|
||||
severity: "error",
|
||||
message: `Found ${matches.length} asset path(s) using ../capture/ — will 404 in Studio and renders.`,
|
||||
message:
|
||||
`Found ${offenders.length} asset path(s) traversing above the project root with "../" ` +
|
||||
`(${prefixSummary}). Renders rewrite this against each sub-composition's source path, but Studio preview and other live consumers resolve against the project root and 404.`,
|
||||
fixHint:
|
||||
'Replace all "../capture/" with "capture/" throughout this file. Compositions are served with the project root as their base URL, so paths must be root-relative, not relative to the compositions/ directory.',
|
||||
'Use plain root-relative paths (e.g. "assets/...", "capture/...", "fonts/...") — compositions are served with the project root as their base URL, so paths must be root-relative, not relative to the compositions/ directory.',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -523,6 +523,212 @@ describe("loadExternalCompositions", () => {
|
||||
expect(host2.querySelector("p")?.textContent).toBe("B");
|
||||
});
|
||||
|
||||
describe("asset path rewriting (Studio preview parity with render)", () => {
|
||||
/**
|
||||
* Authored compositions live at `compositions/frames/*.html` and may
|
||||
* reference assets either as project-root-relative (`assets/x.mp4`,
|
||||
* which already resolves against the main document's base) or as
|
||||
* sub-comp-relative with `../../` (which the server-side bundler
|
||||
* rewrites for the baked render, but which historically broke in
|
||||
* Studio preview because the runtime did no such rewriting and the
|
||||
* `../../` traversed above the project root).
|
||||
*
|
||||
* These tests pin the rewrite contract so the runtime stays in
|
||||
* lockstep with the producer's `inlineSubCompositions` path.
|
||||
*/
|
||||
const FRAME_URL =
|
||||
"http://localhost:5190/api/projects/demo/preview/compositions/frames/scene.html";
|
||||
|
||||
it("rewrites `../`-traversing src on elements inside <template>", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", FRAME_URL);
|
||||
host.setAttribute("data-composition-id", "scene");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html><body>
|
||||
<template>
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<video id="hero" src="../../assets/hero.mp4"></video>
|
||||
<img id="badge" src="../../assets/badge.png" />
|
||||
</div>
|
||||
</template>
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(compositionHtml, { status: 200 }),
|
||||
);
|
||||
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
const hero = host.querySelector("#hero");
|
||||
const badge = host.querySelector("#badge");
|
||||
expect(hero?.getAttribute("src")).toBe(
|
||||
"http://localhost:5190/api/projects/demo/preview/assets/hero.mp4",
|
||||
);
|
||||
expect(badge?.getAttribute("src")).toBe(
|
||||
"http://localhost:5190/api/projects/demo/preview/assets/badge.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves plain project-root-relative paths untouched (no double-prefix)", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", FRAME_URL);
|
||||
host.setAttribute("data-composition-id", "scene");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html><body>
|
||||
<template>
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<video id="hero" src="assets/hero.mp4"></video>
|
||||
<img id="badge" src="assets/badge.png" />
|
||||
</div>
|
||||
</template>
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(compositionHtml, { status: 200 }),
|
||||
);
|
||||
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
// Plain relative paths resolve against the main document's base, which
|
||||
// points at the project preview root — so the runtime must NOT rewrite
|
||||
// them. Doing so would risk double-prefixing the URL.
|
||||
const hero = host.querySelector("#hero");
|
||||
const badge = host.querySelector("#badge");
|
||||
expect(hero?.getAttribute("src")).toBe("assets/hero.mp4");
|
||||
expect(badge?.getAttribute("src")).toBe("assets/badge.png");
|
||||
});
|
||||
|
||||
it("leaves absolute URLs, data URIs, and hash refs untouched", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", FRAME_URL);
|
||||
host.setAttribute("data-composition-id", "scene");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html><body>
|
||||
<template>
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<video id="abs" src="https://cdn.example.com/clip.mp4"></video>
|
||||
<img id="dat" src="data:image/png;base64,AA" />
|
||||
<a id="hash" href="#main">jump</a>
|
||||
<img id="root" src="/global/logo.png" />
|
||||
</div>
|
||||
</template>
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(compositionHtml, { status: 200 }),
|
||||
);
|
||||
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
expect(host.querySelector("#abs")?.getAttribute("src")).toBe(
|
||||
"https://cdn.example.com/clip.mp4",
|
||||
);
|
||||
expect(host.querySelector("#dat")?.getAttribute("src")).toBe("data:image/png;base64,AA");
|
||||
expect(host.querySelector("#hash")?.getAttribute("href")).toBe("#main");
|
||||
expect(host.querySelector("#root")?.getAttribute("src")).toBe("/global/logo.png");
|
||||
});
|
||||
|
||||
it("rewrites CSS url(...) `../` references inside <style> blocks", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", FRAME_URL);
|
||||
host.setAttribute("data-composition-id", "scene");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html><body>
|
||||
<template>
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
@font-face { font-family: 'Brand'; src: url("../../assets/fonts/brand.woff2") format("woff2"); }
|
||||
.cover { background-image: url('../../assets/cover.png'); }
|
||||
.icon { background-image: url(assets/icon.svg); }
|
||||
</style>
|
||||
<p>scoped</p>
|
||||
</div>
|
||||
</template>
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
const injectedStyles: HTMLStyleElement[] = [];
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(compositionHtml, { status: 200 }),
|
||||
);
|
||||
await loadExternalCompositions({ ...defaultParams, injectedStyles });
|
||||
|
||||
const cssText = injectedStyles.map((s) => s.textContent || "").join("\n");
|
||||
expect(cssText).toContain(
|
||||
'url("http://localhost:5190/api/projects/demo/preview/assets/fonts/brand.woff2")',
|
||||
);
|
||||
expect(cssText).toContain(
|
||||
"url('http://localhost:5190/api/projects/demo/preview/assets/cover.png')",
|
||||
);
|
||||
// Plain relative path stays untouched — the main document's base
|
||||
// already covers it, and double-prefixing would 404.
|
||||
expect(cssText).toContain("url(assets/icon.svg)");
|
||||
});
|
||||
|
||||
it("rewrites url(...) inside inline style attributes", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", FRAME_URL);
|
||||
host.setAttribute("data-composition-id", "scene");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html><body>
|
||||
<template>
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<div id="card" style="background-image: url('../../assets/card-bg.png');"></div>
|
||||
</div>
|
||||
</template>
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(compositionHtml, { status: 200 }),
|
||||
);
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
const card = host.querySelector("#card");
|
||||
expect(card?.getAttribute("style")).toContain(
|
||||
"url('http://localhost:5190/api/projects/demo/preview/assets/card-bg.png')",
|
||||
);
|
||||
});
|
||||
|
||||
it("rewrites `../`-traversing src on non-template (full HTML doc) sub-comps", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", FRAME_URL);
|
||||
host.setAttribute("data-composition-id", "scene");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html><body>
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<video id="hero" src="../../assets/hero.mp4"></video>
|
||||
</div>
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(compositionHtml, { status: 200 }),
|
||||
);
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
const hero = host.querySelector("#hero");
|
||||
expect(hero?.getAttribute("src")).toBe(
|
||||
"http://localhost:5190/api/projects/demo/preview/assets/hero.mp4",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("variable scoping (window.__hfVariablesByComp)", () => {
|
||||
type WindowWithScopedVars = Window & {
|
||||
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
|
||||
|
||||
@@ -26,6 +26,117 @@ type PendingScript =
|
||||
|
||||
const EXTERNAL_SCRIPT_LOAD_TIMEOUT_MS = 8000;
|
||||
const BARE_RELATIVE_PATH_RE = /^(?![a-zA-Z][a-zA-Z\d+\-.]*:)(?!\/\/)(?!\/)(?!\.\.?\/).+/;
|
||||
const CSS_URL_RE = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
|
||||
const PATH_ATTRS = ["src", "href"] as const;
|
||||
|
||||
/**
|
||||
* Return true for URLs/prefixes that should never be rewritten — absolute
|
||||
* URLs, protocol-relative, data:, hash fragments, root-relative. Mirrors
|
||||
* the compiler's `isNonRelativeUrl` so server-side bundling and client-side
|
||||
* runtime rewrite use the same rules.
|
||||
*/
|
||||
function isNonRelativeRuntimeUrl(value: string): boolean {
|
||||
return (
|
||||
!value ||
|
||||
value.startsWith("http://") ||
|
||||
value.startsWith("https://") ||
|
||||
value.startsWith("//") ||
|
||||
value.startsWith("data:") ||
|
||||
value.startsWith("#") ||
|
||||
value.startsWith("/")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a relative asset path from a sub-composition's URL to one that
|
||||
* works in the live document.
|
||||
*
|
||||
* Server-side `inlineSubCompositions` rewrites `../foo.svg` from
|
||||
* `compositions/scene.html` to `foo.svg` (project root). When the runtime
|
||||
* mounts a sub-composition by fetching its HTML and importing its nodes
|
||||
* into the main document, no such rewriting happens — so a `<video
|
||||
* src="../../assets/x.mp4">` authored from `compositions/frames/*.html`
|
||||
* resolves against the main document's base, climbing **above** the
|
||||
* project root (e.g. `/api/projects/assets/x.mp4`) and 404s. This is the
|
||||
* Studio-preview-vs-render divergence noted in the bug report.
|
||||
*
|
||||
* For each path that traverses up with `../`, resolve against the
|
||||
* sub-composition's URL and return an absolute URL the browser can use
|
||||
* directly. Plain relative paths (`assets/x.mp4`) and absolute / special
|
||||
* URLs are returned unchanged — they already resolve correctly via the
|
||||
* main document's base.
|
||||
*/
|
||||
function rewriteRuntimeAssetPath(value: string, compositionUrl: URL | null): string {
|
||||
if (!compositionUrl) return value;
|
||||
const trimmed = value.trim();
|
||||
if (isNonRelativeRuntimeUrl(trimmed)) return value;
|
||||
if (!trimmed.startsWith("../") && trimmed !== "..") return value;
|
||||
try {
|
||||
return new URL(trimmed, compositionUrl).href;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteRuntimeCssAssetUrls(cssText: string, compositionUrl: URL | null): string {
|
||||
if (!compositionUrl || !cssText) return cssText;
|
||||
return cssText.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => {
|
||||
const rewritten = rewriteRuntimeAssetPath(rawUrl || "", compositionUrl);
|
||||
if (rewritten === rawUrl) return full;
|
||||
return `url(${quote || ""}${rewritten}${quote || ""})`;
|
||||
});
|
||||
}
|
||||
|
||||
function rewritePathAttrsInTree(root: ParentNode, compositionUrl: URL): void {
|
||||
for (const el of Array.from(root.querySelectorAll<Element>("[src], [href]"))) {
|
||||
for (const attr of PATH_ATTRS) {
|
||||
const value = el.getAttribute(attr);
|
||||
if (value == null) continue;
|
||||
const rewritten = rewriteRuntimeAssetPath(value, compositionUrl);
|
||||
if (rewritten !== value) el.setAttribute(attr, rewritten);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteInlineStyleUrlsInTree(root: ParentNode, compositionUrl: URL): void {
|
||||
for (const el of Array.from(root.querySelectorAll<Element>("[style]"))) {
|
||||
const value = el.getAttribute("style");
|
||||
if (value == null) continue;
|
||||
const rewritten = rewriteRuntimeCssAssetUrls(value, compositionUrl);
|
||||
if (rewritten !== value) el.setAttribute("style", rewritten);
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteStyleElementUrlsInTree(root: ParentNode, compositionUrl: URL): void {
|
||||
for (const styleEl of Array.from(root.querySelectorAll<HTMLStyleElement>("style"))) {
|
||||
const text = styleEl.textContent || "";
|
||||
const rewritten = rewriteRuntimeCssAssetUrls(text, compositionUrl);
|
||||
if (rewritten !== text) styleEl.textContent = rewritten;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite relative asset paths in a parsed sub-composition document so
|
||||
* that `../`-traversing paths resolve against the sub-composition's URL
|
||||
* rather than the main document's base. Touches `[src]`, `[href]`,
|
||||
* `[style]` url(...) references, and `<style>` element CSS — the same
|
||||
* surface the server-side `inlineSubCompositions` rewrites.
|
||||
*
|
||||
* Recurses into `<template>` content because authored compositions wrap
|
||||
* their rendered body in a `<template>` and querySelectorAll does not
|
||||
* enter template content (it lives in a detached DocumentFragment).
|
||||
* Without recursion, the rewrite would miss every `<video>` and
|
||||
* `<img>` that an author placed inside the canonical template wrapper.
|
||||
*/
|
||||
function rewriteSubCompositionAssetPaths(root: ParentNode, compositionUrl: URL | null): void {
|
||||
if (!compositionUrl) return;
|
||||
rewritePathAttrsInTree(root, compositionUrl);
|
||||
rewriteInlineStyleUrlsInTree(root, compositionUrl);
|
||||
rewriteStyleElementUrlsInTree(root, compositionUrl);
|
||||
for (const templateEl of Array.from(root.querySelectorAll<HTMLTemplateElement>("template"))) {
|
||||
rewriteSubCompositionAssetPaths(templateEl.content, compositionUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueCompositionId(baseId: string, index: number): string {
|
||||
return `${baseId}__hf${index}`;
|
||||
@@ -580,6 +691,16 @@ export async function loadExternalCompositions(
|
||||
const html = await response.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
// Rewrite project-root-traversing (`../`) asset paths against the
|
||||
// sub-composition's URL before extracting any nodes. Without this,
|
||||
// `<video src="../../assets/x.mp4">` authored from
|
||||
// `compositions/frames/scene.html` resolves against the main
|
||||
// document's base (the project preview root) and climbs above it
|
||||
// to 404 — the Studio-preview-vs-render divergence reported by
|
||||
// OSS users. The server-side bundler already does this for the
|
||||
// baked render via `inlineSubCompositions`; this is the runtime
|
||||
// mirror so live preview matches.
|
||||
rewriteSubCompositionAssetPaths(doc, compositionUrl);
|
||||
const template =
|
||||
(authoredCompositionId
|
||||
? doc.querySelector<HTMLTemplateElement>(
|
||||
|
||||
Reference in New Issue
Block a user