Skip to content
Open
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
56 changes: 56 additions & 0 deletions packages/lint/src/rules/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,62 @@ ${headContent}
}

describe("core rules", () => {
it("warns when a custom-positioned clip inherits opposite inset edges", async () => {
const html = `
<html><head><style>
.clip { position: absolute; inset: 0; }
.badge { top: 40px; right: 40px; border-radius: 999px; }
</style></head><body>
<div data-composition-id="c1" data-width="1080" data-height="1920">
<div class="clip badge">Badge</div>
</div>
<script>window.__timelines = {};</script>
</body></html>`;

const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "clip_inset_overconstrained");

expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.selector).toBe(".badge");
expect(finding?.fixHint).toContain("left: auto");
expect(finding?.fixHint).toContain("bottom: auto");
});

it("accepts a custom-positioned clip that resets inherited opposite edges", async () => {
const html = `
<html><head><style>
.clip { position: absolute; inset: 0; }
.badge { top: 40px; right: 40px; left: auto; bottom: auto; border-radius: 999px; }
</style></head><body>
<div data-composition-id="c1" data-width="1080" data-height="1920">
<div class="clip badge">Badge</div>
</div>
<script>window.__timelines = {};</script>
</body></html>`;

const result = await lintHyperframeHtml(html);

expect(result.findings.find((f) => f.code === "clip_inset_overconstrained")).toBeUndefined();
});

it("accepts a custom-positioned clip with explicit dimensions", async () => {
const html = `
<html><head><style>
.clip { position: absolute; inset: 0; }
.badge { top: 40px; right: 40px; width: 120px; height: 48px; border-radius: 999px; }
</style></head><body>
<div data-composition-id="c1" data-width="1080" data-height="1920">
<div class="clip badge">Badge</div>
</div>
<script>window.__timelines = {};</script>
</body></html>`;

const result = await lintHyperframeHtml(html);

expect(result.findings.find((f) => f.code === "clip_inset_overconstrained")).toBeUndefined();
});

it("reports error when root is missing data-composition-id", async () => {
const html = `
<html><body>
Expand Down
100 changes: 100 additions & 0 deletions packages/lint/src/rules/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ function selectorTargetsCompositionId(selector: string, compositionId: string):
).test(selector);
}

function selectorMatchesClassList(selector: string, classes: Set<string>): boolean {
const target =
selector
.trim()
.split(/\s+|>|\+|~/)
.at(-1) ?? "";
const required = [...target.matchAll(/\.([\w-]+)/g)].flatMap((match) =>
match[1] ? [match[1]] : [],
);
return required.length > 0 && required.every((className) => classes.has(className));
}

function isActiveInset(value: string | undefined): boolean {
return value !== undefined && value.trim().toLowerCase() !== "auto";
}

function isStudioTimelineElement(tag: { raw: string; name: string }): boolean {
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) {
return false;
Expand Down Expand Up @@ -181,6 +197,90 @@ function findVisibleMarkupCommentLeak(source: string): string | null {
}

export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// clip_inset_overconstrained
// `.clip { inset: 0 }` is the full-canvas default. A custom-positioning rule
// that changes only top/right (or another subset) retains the opposite edges,
// so an auto-sized absolute element silently stretches across the canvas.
({ tags, styles }) => {
const findings: HyperframeLintFinding[] = [];
const parsedRules: postcss.Rule[] = [];
for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
root.walkRules((rule) => {
parsedRules.push(rule);
});
}

for (const tag of tags) {
const classNames = (readAttr(tag.raw, "class") ?? "").split(/\s+/).filter(Boolean);
if (!classNames.includes("clip") || classNames.length < 2) continue;
const classes = new Set(classNames);
const edges = new Map<string, string>();
let inheritedInset = false;
let customSelector: string | undefined;

for (const rule of parsedRules) {
const matchingSelectors = rule.selectors.filter((selector) =>
selectorMatchesClassList(selector, classes),
);
if (matchingSelectors.length === 0) continue;
rule.walkDecls((decl) => {
const property = decl.prop.trim().toLowerCase();
if (property === "inset") {
const values = decl.value.trim().split(/\s+/);
if (values.length === 1 && isActiveInset(values[0])) {
for (const edge of ["top", "right", "bottom", "left"]) edges.set(edge, values[0]!);
if (matchingSelectors.some((selector) => /\.clip(?:\b|[.#:[>+~])/.test(selector))) {
inheritedInset = true;
}
}
return;
}
if (!["top", "right", "bottom", "left", "width", "height"].includes(property)) {
return;
}
edges.set(property, decl.value);
if (property !== "width" && property !== "height") {
const selector = matchingSelectors.find((candidate) => !/^\.clip\s*$/.test(candidate));
if (selector) customSelector = selector;
}
});
}

if (!inheritedInset || !customSelector) continue;
const horizontalStretch =
isActiveInset(edges.get("left")) &&
isActiveInset(edges.get("right")) &&
!isActiveInset(edges.get("width"));
const verticalStretch =
isActiveInset(edges.get("top")) &&
isActiveInset(edges.get("bottom")) &&
!isActiveInset(edges.get("height"));
if (!horizontalStretch && !verticalStretch) continue;

const resets = [
horizontalStretch ? "left: auto" : null,
verticalStretch ? "bottom: auto" : null,
]
.filter(Boolean)
.join("; ");
findings.push({
code: "clip_inset_overconstrained",
severity: "warning",
message: `\`${customSelector}\` custom-positions a .clip element but retains opposite edges from \`.clip { inset: 0 }\`, so its auto size stretches across the canvas.`,
selector: customSelector,
fixHint: `Reset the inherited opposite edges (${resets}) or set an explicit width/height for the intended custom placement.`,
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},

// root_missing_composition_id + root_missing_dimensions
({ rootTag }) => {
const findings: HyperframeLintFinding[] = [];
Expand Down
Loading