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
184 changes: 177 additions & 7 deletions packages/core/src/lint/rules/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand All @@ -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 <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 CSS url() uses ../capture/ (counts all occurrences)", async () => {
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">
Expand All @@ -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", () => {
Expand Down
87 changes: 76 additions & 11 deletions packages/core/src/lint/rules/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.',
},
];
},
Expand Down
Loading
Loading