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 = ` + +
+ +

Hello

+
+ +`; + 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 = ` + +
+ +`; + 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 = ` + +
+ +
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector"); + + expect(finding).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/lint/rules/core.ts b/packages/core/src/lint/rules/core.ts index dd5a2300c..eddb0a5f3 100644 --- a/packages/core/src/lint/rules/core.ts +++ b/packages/core/src/lint/rules/core.ts @@ -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(); + 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[] = []; diff --git a/packages/core/src/lint/types.ts b/packages/core/src/lint/types.ts index 17db97b08..69dfeb758 100644 --- a/packages/core/src/lint/types.ts +++ b/packages/core/src/lint/types.ts @@ -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.