diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index bc0364838..3c72febc4 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -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": `
+ + +`, + }); + 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(), diff --git a/packages/cli/src/utils/lintProject.ts b/packages/cli/src/utils/lintProject.ts index c119a141f..45eef143c 100644 --- a/packages/cli/src/utils/lintProject.ts +++ b/packages/cli/src/utils/lintProject.ts @@ -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 = /]*>/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; diff --git a/packages/core/src/lint/context.ts b/packages/core/src/lint/context.ts index acd0fecba..9de887df5 100644 --- a/packages/core/src/lint/context.ts +++ b/packages/core/src/lint/context.ts @@ -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); diff --git a/packages/core/src/lint/rules/core.test.ts b/packages/core/src/lint/rules/core.test.ts index 6c0d0f26a..04b7fd34e 100644 --- a/packages/core/src/lint/rules/core.test.ts +++ b/packages/core/src/lint/rules/core.test.ts @@ -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 = ` + +