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:
James Russo
2026-06-24 07:48:38 -07:00
committed by GitHub
parent 5242dde2dc
commit c7b9bf3386
4 changed files with 580 additions and 18 deletions
@@ -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", () => {
+76 -11
View File
@@ -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.',
},
];
},