mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix: warn on self-scoped composition selectors (#562)
This commit is contained in:
@@ -99,6 +99,28 @@ describe("lintProject", () => {
|
||||
expect(subFindings.some((f) => f.code === "media_missing_id")).toBe(true);
|
||||
});
|
||||
|
||||
it("lints linked CSS next to sub-compositions", () => {
|
||||
const project = makeProject(validHtml(), {
|
||||
"scene.html": `<html><head><link rel="stylesheet" href="scene.css"></head><body>
|
||||
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080" data-start="0" data-duration="2"></div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`,
|
||||
});
|
||||
writeFileSync(
|
||||
join(project.dir, "compositions", "scene.css"),
|
||||
'[data-composition-id="scene"] .title { opacity: 0; }',
|
||||
);
|
||||
|
||||
const { results } = lintProject(project);
|
||||
const subResult = results.find((result) => result.file === "compositions/scene.html");
|
||||
const finding = subResult?.result.findings.find(
|
||||
(item) => item.code === "composition_self_attribute_selector",
|
||||
);
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe('[data-composition-id="scene"] .title');
|
||||
});
|
||||
|
||||
it("aggregates errors across index.html and sub-compositions", () => {
|
||||
const project = makeProject(htmlWithMissingMediaId(), {
|
||||
"overlay.html": htmlWithMissingMediaId(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join, resolve, extname } from "node:path";
|
||||
import { dirname, join, resolve, extname } from "node:path";
|
||||
import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint";
|
||||
import type { HyperframeLintFinding } from "@hyperframes/core/lint";
|
||||
import { rewriteAssetPath } from "@hyperframes/core";
|
||||
@@ -27,6 +27,32 @@ export interface ProjectLintResult {
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]);
|
||||
|
||||
function isLocalStylesheetHref(href: string): boolean {
|
||||
return !!href && !/^(https?:|data:|blob:|\/\/)/i.test(href);
|
||||
}
|
||||
|
||||
function collectExternalStyles(
|
||||
projectDir: string,
|
||||
html: string,
|
||||
compSrcPath?: string,
|
||||
): Array<{ href: string; content: string }> {
|
||||
const styles: Array<{ href: string; content: string }> = [];
|
||||
const linkRe = /<link\b[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = linkRe.exec(html)) !== null) {
|
||||
const tag = match[0];
|
||||
const rel = tag.match(/\brel\s*=\s*["']([^"']+)["']/i)?.[1] ?? "";
|
||||
if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue;
|
||||
const href = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] ?? "";
|
||||
if (!isLocalStylesheetHref(href)) continue;
|
||||
const rootRelative = compSrcPath ? join(dirname(compSrcPath), href) : href;
|
||||
const resolved = resolve(projectDir, rootRelative);
|
||||
if (!existsSync(resolved)) continue;
|
||||
styles.push({ href, content: readFileSync(resolved, "utf-8") });
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint the root index.html and all sub-compositions in the compositions/ directory.
|
||||
* Returns aggregated results across all files.
|
||||
@@ -39,7 +65,10 @@ export function lintProject(project: ProjectDir): ProjectLintResult {
|
||||
|
||||
// Lint root composition
|
||||
const rootHtml = readFileSync(project.indexPath, "utf-8");
|
||||
const rootResult = lintHyperframeHtml(rootHtml, { filePath: project.indexPath });
|
||||
const rootResult = lintHyperframeHtml(rootHtml, {
|
||||
filePath: project.indexPath,
|
||||
externalStyles: collectExternalStyles(project.dir, rootHtml),
|
||||
});
|
||||
results.push({ file: "index.html", result: rootResult });
|
||||
totalErrors += rootResult.errorCount;
|
||||
totalWarnings += rootResult.warningCount;
|
||||
@@ -53,8 +82,13 @@ export function lintProject(project: ProjectDir): ProjectLintResult {
|
||||
for (const file of files) {
|
||||
const filePath = join(compositionsDir, file);
|
||||
const html = readFileSync(filePath, "utf-8");
|
||||
allHtmlSources.push({ html, compSrcPath: `compositions/${file}` });
|
||||
const result = lintHyperframeHtml(html, { filePath, isSubComposition: true });
|
||||
const compSrcPath = `compositions/${file}`;
|
||||
allHtmlSources.push({ html, compSrcPath });
|
||||
const result = lintHyperframeHtml(html, {
|
||||
filePath,
|
||||
isSubComposition: true,
|
||||
externalStyles: collectExternalStyles(project.dir, html, compSrcPath),
|
||||
});
|
||||
results.push({ file: `compositions/${file}`, result });
|
||||
totalErrors += result.errorCount;
|
||||
totalWarnings += result.warningCount;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user