fix: warn on self-scoped composition selectors (#562)

This commit is contained in:
Miguel Ángel
2026-04-29 18:13:51 +02:00
committed by GitHub
parent 328bba0384
commit 403c00eeae
6 changed files with 176 additions and 5 deletions
+9 -1
View File
@@ -34,7 +34,15 @@ export function buildLintContext(html: string, options: HyperframeLinterOptions
if (templateMatch?.[1]) source = templateMatch[1];
const tags = extractOpenTags(source);
const styles = extractBlocks(source, STYLE_BLOCK_PATTERN);
const styles = [
...extractBlocks(source, STYLE_BLOCK_PATTERN),
...(options.externalStyles ?? []).map((style) => ({
attrs: `href="${style.href}"`,
content: style.content,
raw: style.content,
index: -1,
})),
];
const scripts = extractBlocks(source, SCRIPT_BLOCK_PATTERN);
const compositionIds = collectCompositionIds(tags);
const rootTag = findRootTag(source);
+60
View File
@@ -143,4 +143,64 @@ describe("core rules", () => {
expect(finding).toBeUndefined();
});
});
describe("composition_self_attribute_selector", () => {
it("warns when inline CSS targets the root composition id", () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080">
<style>
[data-composition-id="scene"] .title { opacity: 0; }
[data-composition-id="other"] .title { color: red; }
</style>
<h1 class="title">Hello</h1>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const findings = result.findings.filter(
(f) => f.code === "composition_self_attribute_selector",
);
expect(findings).toHaveLength(1);
expect(findings[0]?.severity).toBe("warning");
expect(findings[0]?.selector).toBe('[data-composition-id="scene"] .title');
expect(findings[0]?.fixHint).toContain("#scene");
expect(findings[0]?.fixHint).not.toContain("#556");
});
it("warns when external CSS targets the root composition id", () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html, {
externalStyles: [
{
href: "scene.css",
content: '[data-composition-id="scene"] .title { opacity: 0; }',
},
],
});
const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector");
expect(finding).toBeDefined();
expect(finding?.selector).toBe('[data-composition-id="scene"] .title');
});
it("does not warn when CSS targets a different composition id", () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080">
<style>[data-composition-id="other"] .title { opacity: 0; }</style>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector");
expect(finding).toBeUndefined();
});
});
});
+46
View File
@@ -1,4 +1,5 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import postcss from "postcss";
import {
readAttr,
truncateSnippet,
@@ -9,6 +10,17 @@ import {
INVALID_SCRIPT_CLOSE_PATTERN,
} from "../utils";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function selectorTargetsCompositionId(selector: string, compositionId: string): boolean {
const escaped = escapeRegExp(compositionId);
return new RegExp(
String.raw`\[\s*data-composition-id\s*=\s*(?:"${escaped}"|'${escaped}')\s*\]`,
).test(selector);
}
export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// root_missing_composition_id + root_missing_dimensions
({ rootTag }) => {
@@ -167,6 +179,40 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
return findings;
},
// composition_self_attribute_selector
({ styles, rootCompositionId, rootTag }) => {
const findings: HyperframeLintFinding[] = [];
if (!rootCompositionId) return findings;
const seenSelectors = new Set<string>();
const rootId = readAttr(rootTag?.raw || "", "id");
for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
root.walkRules((rule) => {
for (const selector of rule.selectors) {
if (!selectorTargetsCompositionId(selector, rootCompositionId)) continue;
if (seenSelectors.has(selector)) continue;
seenSelectors.add(selector);
findings.push({
code: "composition_self_attribute_selector",
severity: "warning",
message:
"Selector matches the block's own id; will leak to sibling instances when the block is embedded twice.",
selector,
fixHint: rootId
? `Use #${rootId} for clearer authoring intent and instance-isolated styling.`
: "Add a stable id to the composition root and use that id selector for clearer authoring intent and instance-isolated styling.",
});
}
});
}
return findings;
},
// non_deterministic_code
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
+1
View File
@@ -22,6 +22,7 @@ export type HyperframeLintResult = {
export type HyperframeLinterOptions = {
filePath?: string;
isSubComposition?: boolean;
externalStyles?: Array<{ href: string; content: string }>;
};
// A rule is a pure function: receives parsed context, returns zero or more findings.