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
@@ -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(),
+38 -4
View File
@@ -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;