Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/cli/src/utils/lintProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
42 changes: 38 additions & 4 deletions packages/cli/src/utils/lintProject.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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;
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/lint/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
60 changes: 60 additions & 0 deletions packages/core/src/lint/rules/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 46 additions & 0 deletions packages/core/src/lint/rules/core.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import postcss from "postcss";
import {
readAttr,
truncateSnippet,
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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[] = [];
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/lint/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading