@@ -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 = `
+
+
+
+ `;
+ 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 = `
+
+

+
+
+
+ `;
+ 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 = `
+
+

+
+
+ `;
+ 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 = `
+
+
+
+ `;
+ 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 = `
+
+ `;
+ 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 = `
+
+

+
+ `;
+ 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 = `\n
+
+

+
+ `;
+ 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 = `
+
+

+
+ `;
+ 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", () => {
diff --git a/packages/core/src/lint/rules/composition.ts b/packages/core/src/lint/rules/composition.ts
index 63be85fa5..19e20fd0c 100644
--- a/packages/core/src/lint/rules/composition.ts
+++ b/packages/core/src/lint/rules/composition.ts
@@ -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
+
scoped
+
+
+
+ `;
+
+ 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 = `
+
+