mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
refactor(lint): break 1,314-line monolith into focused rule modules with plugin system (#170)
## Summary
- Breaks `hyperframeLinter.ts` from 1,314 lines (one massive function) into a plugin system of focused rule modules
- Introduces `LintContext` — HTML is parsed once and shared across all rules
- Adds `LintRule<TContext>` type as the formal contract for rules
- Public API unchanged: `lintHyperframeHtml`, `lintMediaUrls`, `lintScriptUrls` signatures identical
## New file structure
```
src/lint/
utils.ts — shared types (OpenTag, ExtractedBlock), regex constants, helpers
context.ts — LintContext type + buildLintContext() factory
rules/
core.ts — structural rules (root attrs, timeline registry, script syntax)
media.ts — media element rules (duplicate ids, video pitfalls, placeholder URLs, etc.)
gsap.ts — GSAP rules + GSAP-specific parsing utils
captions.ts — caption rules
composition.ts — timed element, deprecated attrs, template literal selector, external scripts
adapters.ts — Lottie + Three.js missing-script rules (from PR #149)
hyperframeLinter.ts — orchestrator only (~200 lines, down from 1,314)
```
## Adding a new adapter rule going forward
1. Create `src/lint/rules/my-adapter.ts` exporting `myAdapterRules: LintRule[]`
2. Import and spread into `ALL_RULES` in `hyperframeLinter.ts`
## Test plan
- [x] All 402 core tests pass unchanged
- [x] Full workspace build clean (`pnpm build`)
- [x] TypeScript strict mode clean (`pnpm tsc --noEmit`)
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import type { HyperframeLintFinding, HyperframeLinterOptions } from "./types";
|
||||
import {
|
||||
extractBlocks,
|
||||
extractOpenTags,
|
||||
findRootTag,
|
||||
collectCompositionIds,
|
||||
readAttr,
|
||||
STYLE_BLOCK_PATTERN,
|
||||
SCRIPT_BLOCK_PATTERN,
|
||||
} from "./utils";
|
||||
import type { OpenTag, ExtractedBlock } from "./utils";
|
||||
|
||||
export type { OpenTag, ExtractedBlock };
|
||||
|
||||
export type LintContext = {
|
||||
source: string;
|
||||
tags: OpenTag[];
|
||||
styles: ExtractedBlock[];
|
||||
scripts: ExtractedBlock[];
|
||||
compositionIds: Set<string>;
|
||||
rootTag: OpenTag | null;
|
||||
rootCompositionId: string | null;
|
||||
options: HyperframeLinterOptions;
|
||||
};
|
||||
|
||||
// Re-export for convenience so rule modules only need one import for the finding type
|
||||
export type { HyperframeLintFinding };
|
||||
|
||||
export function buildLintContext(html: string, options: HyperframeLinterOptions = {}): LintContext {
|
||||
let source = html || "";
|
||||
const templateMatch = source.match(/<template[^>]*>([\s\S]*)<\/template>/i);
|
||||
if (templateMatch?.[1]) source = templateMatch[1];
|
||||
|
||||
const tags = extractOpenTags(source);
|
||||
const styles = extractBlocks(source, STYLE_BLOCK_PATTERN);
|
||||
const scripts = extractBlocks(source, SCRIPT_BLOCK_PATTERN);
|
||||
const compositionIds = collectCompositionIds(tags);
|
||||
const rootTag = findRootTag(source);
|
||||
const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id");
|
||||
|
||||
return {
|
||||
source,
|
||||
tags,
|
||||
styles,
|
||||
scripts,
|
||||
compositionIds,
|
||||
rootTag,
|
||||
rootCompositionId,
|
||||
options,
|
||||
};
|
||||
}
|
||||
@@ -115,6 +115,7 @@ describe("lintHyperframeHtml", () => {
|
||||
const html = `<template id="rockets-template">
|
||||
<div data-composition-id="rockets" data-width="1920" data-height="1080">
|
||||
<div id="rocket-container"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
@@ -123,10 +124,11 @@ describe("lintHyperframeHtml", () => {
|
||||
</div>
|
||||
</template>`;
|
||||
const result = lintHyperframeHtml(html, { filePath: "compositions/rockets.html" });
|
||||
const finding = result.findings.find((f) => f.code === "external_script_dependency");
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "external_script_dependency" && f.message.includes("cdnjs.cloudflare.com"),
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("info");
|
||||
expect(finding?.message).toContain("cdnjs.cloudflare.com");
|
||||
// info findings do not count as errors — ok should still be true
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.errorCount).toBe(0);
|
||||
@@ -725,4 +727,145 @@ describe("template_literal_selector rule", () => {
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
});
|
||||
|
||||
// ── Missing adapter library script checks ──────────────────────────────
|
||||
|
||||
it("reports error when GSAP is used without a GSAP script tag", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { x: 100, duration: 1 }, 0);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("GSAP");
|
||||
});
|
||||
|
||||
it("does not report missing_gsap_script when GSAP CDN script is present", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { x: 100, duration: 1 }, 0);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when Lottie container exists without a Lottie script tag", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="lottie-player" data-lottie-src="animation.json"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_lottie_script");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("Lottie");
|
||||
});
|
||||
|
||||
it("reports error when lottie.loadAnimation is used without a Lottie script tag", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
lottie.loadAnimation({ container: document.getElementById('lottie'), path: 'anim.json' });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_lottie_script");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("does not report missing_lottie_script when Lottie CDN script is present", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="lottie-player" data-lottie-src="animation.json"></div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lottie-web@5/build/player/lottie.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_lottie_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when Three.js is used without a Three.js script tag", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
const scene = new THREE.Scene();
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_three_script");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("Three.js");
|
||||
});
|
||||
|
||||
it("does not report missing_three_script when Three.js CDN script is present", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
const scene = new THREE.Scene();
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_three_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report any adapter errors for composition with no adapter usage", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="content">Hello World</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = { totalDuration: function() { return 3; } };
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const adapterFindings = result.findings.filter((f) =>
|
||||
["missing_gsap_script", "missing_lottie_script", "missing_three_script"].includes(f.code),
|
||||
);
|
||||
expect(adapterFindings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import { readAttr } from "../utils";
|
||||
|
||||
export const adapterRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// missing_lottie_script
|
||||
({ tags, scripts }) => {
|
||||
const allScriptTexts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
|
||||
const allScriptSrcs = scripts
|
||||
.map((s) => readAttr(`<script ${s.attrs}>`, "src") || "")
|
||||
.filter(Boolean);
|
||||
|
||||
const hasLottieAttr = tags.some((t) => readAttr(t.raw, "data-lottie-src") !== null);
|
||||
const usesLottieApi = allScriptTexts.some((t) =>
|
||||
/lottie\.(loadAnimation|setSpeed|play|stop|destroy)\b/.test(t),
|
||||
);
|
||||
const hasLottieScript = allScriptSrcs.some((src) => /lottie/i.test(src));
|
||||
|
||||
if (!(hasLottieAttr || usesLottieApi) || hasLottieScript) return [];
|
||||
return [
|
||||
{
|
||||
code: "missing_lottie_script",
|
||||
severity: "error",
|
||||
message:
|
||||
"Composition uses Lottie but no Lottie script is loaded. The animation will not render.",
|
||||
fixHint:
|
||||
'Add <script src="https://cdn.jsdelivr.net/npm/lottie-web@5/build/player/lottie.min.js"></script> before your Lottie code.',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// missing_three_script
|
||||
({ scripts }) => {
|
||||
const allScriptTexts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
|
||||
const allScriptSrcs = scripts
|
||||
.map((s) => readAttr(`<script ${s.attrs}>`, "src") || "")
|
||||
.filter(Boolean);
|
||||
|
||||
const usesThree = allScriptTexts.some((t) => /\bTHREE\./.test(t));
|
||||
const hasThreeScript = allScriptSrcs.some((src) => /three/i.test(src));
|
||||
|
||||
if (!usesThree || hasThreeScript) return [];
|
||||
return [
|
||||
{
|
||||
code: "missing_three_script",
|
||||
severity: "error",
|
||||
message:
|
||||
"Composition uses Three.js but no Three.js script is loaded. The 3D scene will not render.",
|
||||
fixHint:
|
||||
'Add <script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"></script> before your Three.js code.',
|
||||
},
|
||||
];
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
|
||||
export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// caption_exit_missing_hard_kill
|
||||
({ scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const script of scripts) {
|
||||
const content = script.content;
|
||||
const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content);
|
||||
const hasHardKill =
|
||||
/\.set\s*\([^,]+,\s*\{[^}]*(?:visibility\s*:\s*["']hidden["']|opacity\s*:\s*0)/.test(
|
||||
content,
|
||||
);
|
||||
const hasCaptionLoop =
|
||||
/forEach|\.forEach\s*\(/.test(content) && /createElement|caption|group|cg-/.test(content);
|
||||
if (hasCaptionLoop && hasExitTween && !hasHardKill) {
|
||||
findings.push({
|
||||
code: "caption_exit_missing_hard_kill",
|
||||
severity: "warning",
|
||||
message:
|
||||
"Caption exit animations (tl.to with opacity: 0) detected without a hard tl.set kill. " +
|
||||
"Exit tweens can fail when karaoke word-level tweens conflict, leaving captions stuck on screen.",
|
||||
fixHint:
|
||||
'Add `tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end)` after every ' +
|
||||
"exit tl.to animation as a deterministic kill.",
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// caption_text_overflow_risk
|
||||
({ styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const style of styles) {
|
||||
const captionBlocks = style.content.matchAll(
|
||||
/(\.caption[-_]?(?:group|container|text|line|word)|#caption[-_]?container)\s*\{([^}]+)\}/gi,
|
||||
);
|
||||
for (const [, selector, body] of captionBlocks) {
|
||||
if (!body) continue;
|
||||
const hasNowrap = /white-space\s*:\s*nowrap/i.test(body);
|
||||
const hasMaxWidth = /max-width/i.test(body);
|
||||
if (hasNowrap && !hasMaxWidth) {
|
||||
findings.push({
|
||||
code: "caption_text_overflow_risk",
|
||||
severity: "warning",
|
||||
selector: (selector ?? "").trim(),
|
||||
message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
|
||||
fixHint:
|
||||
"Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// caption_container_relative_position
|
||||
({ styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const style of styles) {
|
||||
const captionBlocks = style.content.matchAll(
|
||||
/(\.caption[-_]?(?:group|container|text|line)|#caption[-_]?container)\s*\{([^}]+)\}/gi,
|
||||
);
|
||||
for (const [, selector, body] of captionBlocks) {
|
||||
if (!body) continue;
|
||||
if (/position\s*:\s*relative/i.test(body)) {
|
||||
findings.push({
|
||||
code: "caption_container_relative_position",
|
||||
severity: "warning",
|
||||
selector: (selector ?? "").trim(),
|
||||
message: `Caption selector "${(selector ?? "").trim()}" uses position: relative which causes overflow and breaks caption stacking.`,
|
||||
fixHint: "Use position: absolute for all caption elements.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import { readAttr, truncateSnippet } from "../utils";
|
||||
|
||||
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// timed_element_missing_visibility_hidden
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
|
||||
if (!readAttr(tag.raw, "data-start")) continue;
|
||||
if (readAttr(tag.raw, "data-composition-id")) continue;
|
||||
if (readAttr(tag.raw, "data-composition-src")) continue;
|
||||
const classAttr = readAttr(tag.raw, "class") || "";
|
||||
const styleAttr = readAttr(tag.raw, "style") || "";
|
||||
const hasClip = classAttr.split(/\s+/).includes("clip");
|
||||
const hasHiddenStyle =
|
||||
/visibility\s*:\s*hidden/i.test(styleAttr) || /opacity\s*:\s*0/i.test(styleAttr);
|
||||
if (!hasClip && !hasHiddenStyle) {
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "timed_element_missing_visibility_hidden",
|
||||
severity: "info",
|
||||
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has data-start but no class="clip", visibility:hidden, or opacity:0. Consider adding initial hidden state if the element should not be visible before its start time.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
'Add class="clip" (with CSS: .clip { visibility: hidden; }) or style="opacity:0" if the element should start hidden.',
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// deprecated_data_layer + deprecated_data_end
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
if (readAttr(tag.raw, "data-layer") && !readAttr(tag.raw, "data-track-index")) {
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "deprecated_data_layer",
|
||||
severity: "warning",
|
||||
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-layer instead of data-track-index.`,
|
||||
elementId,
|
||||
fixHint: "Replace data-layer with data-track-index. The runtime reads data-track-index.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
if (readAttr(tag.raw, "data-end") && !readAttr(tag.raw, "data-duration")) {
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "deprecated_data_end",
|
||||
severity: "warning",
|
||||
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-end without data-duration. Use data-duration in source HTML.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
"Replace data-end with data-duration. The compiler generates data-end from data-duration automatically.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// template_literal_selector
|
||||
({ scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const script of scripts) {
|
||||
const templateLiteralSelectorPattern =
|
||||
/(?:querySelector|querySelectorAll)\s*\(\s*`[^`]*\$\{[^}]+\}[^`]*`\s*\)/g;
|
||||
let tlMatch: RegExpExecArray | null;
|
||||
while ((tlMatch = templateLiteralSelectorPattern.exec(script.content)) !== null) {
|
||||
findings.push({
|
||||
code: "template_literal_selector",
|
||||
severity: "error",
|
||||
message:
|
||||
"querySelector uses a template literal variable (e.g. `${compId}`). " +
|
||||
"The HTML bundler's CSS parser crashes on these. Use a hardcoded string instead.",
|
||||
fixHint:
|
||||
"Replace the template literal variable with a hardcoded string. The bundler's CSS parser cannot handle interpolated variables in script content.",
|
||||
snippet: truncateSnippet(tlMatch[0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// external_script_dependency
|
||||
({ source }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const externalScriptRe = /<script\b[^>]*\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
const seen = new Set<string>();
|
||||
while ((match = externalScriptRe.exec(source)) !== null) {
|
||||
const src = match[1] ?? "";
|
||||
if (seen.has(src)) continue;
|
||||
seen.add(src);
|
||||
findings.push({
|
||||
code: "external_script_dependency",
|
||||
severity: "info",
|
||||
message: `This composition loads an external script from \`${src}\`. The HyperFrames bundler automatically hoists CDN scripts from sub-compositions into the parent document. In unbundled runtime mode, \`loadExternalCompositions\` re-injects them. If you're using a custom pipeline that bypasses both, you'll need to include this script manually.`,
|
||||
fixHint:
|
||||
"No action needed when using `hyperframes preview` or `hyperframes render`. If using a custom pipeline, add this script tag to your root composition or HTML page.",
|
||||
snippet: truncateSnippet(match[0] ?? ""),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import {
|
||||
readAttr,
|
||||
truncateSnippet,
|
||||
extractCompositionIdsFromCss,
|
||||
getInlineScriptSyntaxError,
|
||||
TIMELINE_REGISTRY_INIT_PATTERN,
|
||||
TIMELINE_REGISTRY_ASSIGN_PATTERN,
|
||||
INVALID_SCRIPT_CLOSE_PATTERN,
|
||||
} from "../utils";
|
||||
|
||||
export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// root_missing_composition_id + root_missing_dimensions
|
||||
({ rootTag }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
if (!rootTag || !readAttr(rootTag.raw, "data-composition-id")) {
|
||||
findings.push({
|
||||
code: "root_missing_composition_id",
|
||||
severity: "error",
|
||||
message: "Root composition is missing `data-composition-id`.",
|
||||
elementId: rootTag ? readAttr(rootTag.raw, "id") || undefined : undefined,
|
||||
fixHint: "Add a stable `data-composition-id` to the entry composition wrapper.",
|
||||
snippet: truncateSnippet(rootTag?.raw || ""),
|
||||
});
|
||||
}
|
||||
if (!rootTag || !readAttr(rootTag.raw, "data-width") || !readAttr(rootTag.raw, "data-height")) {
|
||||
findings.push({
|
||||
code: "root_missing_dimensions",
|
||||
severity: "error",
|
||||
message: "Root composition is missing `data-width` or `data-height`.",
|
||||
elementId: rootTag ? readAttr(rootTag.raw, "id") || undefined : undefined,
|
||||
fixHint: "Set numeric `data-width` and `data-height` on the entry composition root.",
|
||||
snippet: truncateSnippet(rootTag?.raw || ""),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// missing_timeline_registry + timeline_registry_missing_init
|
||||
({ source }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
if (
|
||||
!TIMELINE_REGISTRY_INIT_PATTERN.test(source) &&
|
||||
!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source)
|
||||
) {
|
||||
findings.push({
|
||||
code: "missing_timeline_registry",
|
||||
severity: "error",
|
||||
message: "Missing `window.__timelines` registration.",
|
||||
fixHint: "Register each composition timeline on `window.__timelines[compositionId]`.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) &&
|
||||
!TIMELINE_REGISTRY_INIT_PATTERN.test(source)
|
||||
) {
|
||||
findings.push({
|
||||
code: "timeline_registry_missing_init",
|
||||
severity: "error",
|
||||
message:
|
||||
"`window.__timelines[…] = …` is used without initializing `window.__timelines` first.",
|
||||
fixHint:
|
||||
"Add `window.__timelines = window.__timelines || {};` before any timeline assignment.",
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// timeline_id_mismatch
|
||||
({ source }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const htmlCompIds = new Set<string>();
|
||||
const timelineRegKeys = new Set<string>();
|
||||
const compIdRe = /data-composition-id\s*=\s*["']([^"']+)["']/gi;
|
||||
const tlKeyRe = /window\.__timelines\[\s*["']([^"']+)["']\s*\]/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = compIdRe.exec(source)) !== null) {
|
||||
if (m[1]) htmlCompIds.add(m[1]);
|
||||
}
|
||||
while ((m = tlKeyRe.exec(source)) !== null) {
|
||||
if (m[1]) timelineRegKeys.add(m[1]);
|
||||
}
|
||||
for (const key of timelineRegKeys) {
|
||||
if (!htmlCompIds.has(key)) {
|
||||
findings.push({
|
||||
code: "timeline_id_mismatch",
|
||||
severity: "error",
|
||||
message: `Timeline registered as "${key}" but no element has data-composition-id="${key}". The runtime cannot auto-nest this timeline.`,
|
||||
fixHint: `Change window.__timelines["${key}"] to match the data-composition-id attribute, or vice versa.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// invalid_inline_script_syntax (malformed close tag)
|
||||
({ source }) => {
|
||||
if (!INVALID_SCRIPT_CLOSE_PATTERN.test(source)) return [];
|
||||
return [
|
||||
{
|
||||
code: "invalid_inline_script_syntax",
|
||||
severity: "error",
|
||||
message: "Detected malformed inline `<script>` closing syntax.",
|
||||
fixHint: "Close inline scripts with a valid `</script>` tag.",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// invalid_inline_script_syntax (JS parse error)
|
||||
({ scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const script of scripts) {
|
||||
const attrs = script.attrs || "";
|
||||
if (/\bsrc\s*=/.test(attrs) || /\btype\s*=\s*["']application\/json["']/.test(attrs)) continue;
|
||||
const syntaxError = getInlineScriptSyntaxError(script.content);
|
||||
if (!syntaxError) continue;
|
||||
findings.push({
|
||||
code: "invalid_inline_script_syntax",
|
||||
severity: "error",
|
||||
message: `Inline script has invalid syntax: ${syntaxError}`,
|
||||
fixHint: "Fix the inline script syntax before render verification.",
|
||||
snippet: truncateSnippet(script.content),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// host_missing_composition_id
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
const src = readAttr(tag.raw, "data-composition-src");
|
||||
if (!src) continue;
|
||||
if (readAttr(tag.raw, "data-composition-id")) continue;
|
||||
findings.push({
|
||||
code: "host_missing_composition_id",
|
||||
severity: "error",
|
||||
message: `Composition host for "${src}" is missing \`data-composition-id\`.`,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint: "Set `data-composition-id` on every `data-composition-src` host element.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// scoped_css_missing_wrapper
|
||||
({ styles, compositionIds }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const scopedCssCompositionIds = new Set<string>();
|
||||
for (const style of styles) {
|
||||
for (const compId of extractCompositionIdsFromCss(style.content)) {
|
||||
scopedCssCompositionIds.add(compId);
|
||||
}
|
||||
}
|
||||
for (const compId of scopedCssCompositionIds) {
|
||||
if (compositionIds.has(compId)) continue;
|
||||
findings.push({
|
||||
code: "scoped_css_missing_wrapper",
|
||||
severity: "warning",
|
||||
message: `Scoped CSS targets composition "${compId}" but no matching wrapper exists in this HTML.`,
|
||||
selector: `[data-composition-id="${compId}"]`,
|
||||
fixHint:
|
||||
"Preserve the matching composition wrapper or align the CSS scope to an existing wrapper.",
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,379 @@
|
||||
import { parseGsapScript } from "../../parsers/gsapParser";
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import type { OpenTag } from "../utils";
|
||||
import { readAttr, truncateSnippet, WINDOW_TIMELINE_ASSIGN_PATTERN } from "../utils";
|
||||
|
||||
// ── GSAP-specific types ────────────────────────────────────────────────────
|
||||
|
||||
type GsapWindow = {
|
||||
targetSelector: string;
|
||||
position: number;
|
||||
end: number;
|
||||
properties: string[];
|
||||
overwriteAuto: boolean;
|
||||
method: string;
|
||||
raw: string;
|
||||
};
|
||||
|
||||
const META_GSAP_KEYS = new Set(["duration", "ease", "repeat", "yoyo", "overwrite", "delay"]);
|
||||
|
||||
// ── GSAP parsing utilities ─────────────────────────────────────────────────
|
||||
|
||||
function countClassUsage(tags: OpenTag[]): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const tag of tags) {
|
||||
const classAttr = readAttr(tag.raw, "class");
|
||||
if (!classAttr) continue;
|
||||
for (const className of classAttr.split(/\s+/).filter(Boolean)) {
|
||||
counts.set(className, (counts.get(className) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function readRegisteredTimelineCompositionId(script: string): string | null {
|
||||
const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN);
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
||||
function extractGsapWindows(script: string): GsapWindow[] {
|
||||
if (!/gsap\.timeline/.test(script)) return [];
|
||||
const parsed = parseGsapScript(script);
|
||||
if (parsed.animations.length === 0) return [];
|
||||
|
||||
const windows: GsapWindow[] = [];
|
||||
const timelineVar = parsed.timelineVar;
|
||||
const methodPattern = new RegExp(
|
||||
`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`,
|
||||
"g",
|
||||
);
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
let index = 0;
|
||||
while ((match = methodPattern.exec(script)) !== null && index < parsed.animations.length) {
|
||||
const raw = match[0];
|
||||
const meta = parseGsapWindowMeta(match[1] ?? "", match[2] ?? "");
|
||||
const animation = parsed.animations[index];
|
||||
index += 1;
|
||||
if (!animation) continue;
|
||||
windows.push({
|
||||
targetSelector: animation.targetSelector,
|
||||
position: animation.position,
|
||||
end: animation.position + meta.effectiveDuration,
|
||||
properties: meta.properties.length > 0 ? meta.properties : Object.keys(animation.properties),
|
||||
overwriteAuto: meta.overwriteAuto,
|
||||
method: match[1] ?? "to",
|
||||
raw,
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
function parseGsapWindowMeta(
|
||||
method: string,
|
||||
argsStr: string,
|
||||
): { effectiveDuration: number; properties: string[]; overwriteAuto: boolean } {
|
||||
const selectorMatch = argsStr.match(/^\s*["']([^"']+)["']\s*,/);
|
||||
if (!selectorMatch) return { effectiveDuration: 0, properties: [], overwriteAuto: false };
|
||||
|
||||
const afterSelector = argsStr.slice(selectorMatch[0].length);
|
||||
let properties: Record<string, string | number> = {};
|
||||
let fromProperties: Record<string, string | number> = {};
|
||||
|
||||
if (method === "fromTo") {
|
||||
const firstBrace = afterSelector.indexOf("{");
|
||||
const firstEnd = findMatchingBrace(afterSelector, firstBrace);
|
||||
if (firstBrace !== -1 && firstEnd !== -1) {
|
||||
fromProperties = parseLooseObjectLiteral(afterSelector.slice(firstBrace, firstEnd + 1));
|
||||
const secondPart = afterSelector.slice(firstEnd + 1);
|
||||
const secondBrace = secondPart.indexOf("{");
|
||||
const secondEnd = findMatchingBrace(secondPart, secondBrace);
|
||||
if (secondBrace !== -1 && secondEnd !== -1) {
|
||||
properties = parseLooseObjectLiteral(secondPart.slice(secondBrace, secondEnd + 1));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const braceStart = afterSelector.indexOf("{");
|
||||
const braceEnd = findMatchingBrace(afterSelector, braceStart);
|
||||
if (braceStart !== -1 && braceEnd !== -1) {
|
||||
properties = parseLooseObjectLiteral(afterSelector.slice(braceStart, braceEnd + 1));
|
||||
}
|
||||
}
|
||||
|
||||
const duration = numberValue(properties.duration) || 0;
|
||||
const repeat = numberValue(properties.repeat) || 0;
|
||||
const cycleCount = repeat > 0 ? repeat + 1 : 1;
|
||||
const effectiveDuration = duration * cycleCount;
|
||||
const overwriteAuto = stringValue(properties.overwrite) === "auto";
|
||||
|
||||
const propertyNames = new Set<string>();
|
||||
for (const key of Object.keys(fromProperties)) {
|
||||
if (!META_GSAP_KEYS.has(key)) propertyNames.add(key);
|
||||
}
|
||||
for (const key of Object.keys(properties)) {
|
||||
if (!META_GSAP_KEYS.has(key)) propertyNames.add(key);
|
||||
}
|
||||
|
||||
return {
|
||||
effectiveDuration: method === "set" ? 0 : effectiveDuration,
|
||||
properties: [...propertyNames],
|
||||
overwriteAuto,
|
||||
};
|
||||
}
|
||||
|
||||
function parseLooseObjectLiteral(source: string): Record<string, string | number> {
|
||||
const result: Record<string, string | number> = {};
|
||||
const cleaned = source.replace(/^\{|\}$/g, "").trim();
|
||||
if (!cleaned) return result;
|
||||
const propertyPattern = /(\w+)\s*:\s*("[^"]*"|'[^']*'|true|false|-?[\d.]+|[a-zA-Z_][\w.]*)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = propertyPattern.exec(cleaned)) !== null) {
|
||||
const key = match[1];
|
||||
const rawValue = match[2];
|
||||
if (!key || rawValue == null) continue;
|
||||
if (
|
||||
(rawValue.startsWith('"') && rawValue.endsWith('"')) ||
|
||||
(rawValue.startsWith("'") && rawValue.endsWith("'"))
|
||||
) {
|
||||
result[key] = rawValue.slice(1, -1);
|
||||
continue;
|
||||
}
|
||||
const numeric = Number(rawValue);
|
||||
result[key] = Number.isFinite(numeric) ? numeric : rawValue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findMatchingBrace(source: string, startIndex: number): number {
|
||||
if (startIndex < 0) return -1;
|
||||
let depth = 0;
|
||||
for (let i = startIndex; i < source.length; i++) {
|
||||
if (source[i] === "{") depth += 1;
|
||||
else if (source[i] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function numberValue(value: string | number | undefined): number | null {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function stringValue(value: string | number | undefined): string | null {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number") return String(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSuspiciousGlobalSelector(selector: string): boolean {
|
||||
if (!selector) return false;
|
||||
if (selector.includes("[data-composition-id=")) return false;
|
||||
if (selector.startsWith("#")) return false;
|
||||
return selector.startsWith(".") || /^[a-z]/i.test(selector);
|
||||
}
|
||||
|
||||
function getSingleClassSelector(selector: string): string | null {
|
||||
const match = selector.trim().match(/^\.(?<name>[A-Za-z0-9_-]+)$/);
|
||||
return match?.groups?.name || null;
|
||||
}
|
||||
|
||||
// ── GSAP rules ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector
|
||||
({ tags, scripts, rootCompositionId }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
// Build clip element selector map
|
||||
type ClipInfo = { tag: string; id: string; classes: string };
|
||||
const clipIds = new Map<string, ClipInfo>();
|
||||
const clipClasses = new Map<string, ClipInfo>();
|
||||
for (const tag of tags) {
|
||||
const classAttr = readAttr(tag.raw, "class") || "";
|
||||
const classes = classAttr.split(/\s+/).filter(Boolean);
|
||||
if (!classes.includes("clip")) continue;
|
||||
const id = readAttr(tag.raw, "id");
|
||||
const info: ClipInfo = {
|
||||
tag: tag.name,
|
||||
id: id || "",
|
||||
classes: classAttr,
|
||||
};
|
||||
if (id) clipIds.set(`#${id}`, info);
|
||||
for (const cls of classes) {
|
||||
if (cls !== "clip") clipClasses.set(`.${cls}`, info);
|
||||
}
|
||||
}
|
||||
|
||||
const classUsage = countClassUsage(tags);
|
||||
|
||||
for (const script of scripts) {
|
||||
const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
|
||||
const gsapWindows = extractGsapWindows(script.content);
|
||||
|
||||
// overlapping_gsap_tweens
|
||||
for (let i = 0; i < gsapWindows.length; i++) {
|
||||
const left = gsapWindows[i];
|
||||
if (!left) continue;
|
||||
if (left.end <= left.position) continue;
|
||||
for (let j = i + 1; j < gsapWindows.length; j++) {
|
||||
const right = gsapWindows[j];
|
||||
if (!right) continue;
|
||||
if (right.end <= right.position) continue;
|
||||
if (left.targetSelector !== right.targetSelector) continue;
|
||||
const overlapStart = Math.max(left.position, right.position);
|
||||
const overlapEnd = Math.min(left.end, right.end);
|
||||
if (overlapEnd <= overlapStart) continue;
|
||||
if (left.overwriteAuto || right.overwriteAuto) continue;
|
||||
const sharedProperties = left.properties.filter((prop) =>
|
||||
right.properties.includes(prop),
|
||||
);
|
||||
if (sharedProperties.length === 0) continue;
|
||||
findings.push({
|
||||
code: "overlapping_gsap_tweens",
|
||||
severity: "warning",
|
||||
message: `GSAP tweens overlap on "${left.targetSelector}" for ${sharedProperties.join(", ")} between ${overlapStart.toFixed(2)}s and ${overlapEnd.toFixed(2)}s.`,
|
||||
selector: left.targetSelector,
|
||||
fixHint: 'Shorten the earlier tween, move the later tween, or add `overwrite: "auto"`.',
|
||||
snippet: truncateSnippet(`${left.raw}\n${right.raw}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// gsap_animates_clip_element
|
||||
for (const win of gsapWindows) {
|
||||
const sel = win.targetSelector;
|
||||
const clipInfo = clipIds.get(sel) || clipClasses.get(sel);
|
||||
if (!clipInfo) continue;
|
||||
const elDesc = `<${clipInfo.tag}${clipInfo.id ? ` id="${clipInfo.id}"` : ""} class="${clipInfo.classes}">`;
|
||||
findings.push({
|
||||
code: "gsap_animates_clip_element",
|
||||
severity: "error",
|
||||
message: `GSAP animation targets a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility — animate an inner wrapper instead.`,
|
||||
selector: sel,
|
||||
elementId: clipInfo.id || undefined,
|
||||
fixHint: "Wrap content in a child <div> and target that with GSAP.",
|
||||
snippet: truncateSnippet(win.raw),
|
||||
});
|
||||
}
|
||||
|
||||
// unscoped_gsap_selector
|
||||
if (!localTimelineCompId || localTimelineCompId === rootCompositionId) continue;
|
||||
for (const win of gsapWindows) {
|
||||
if (!isSuspiciousGlobalSelector(win.targetSelector)) continue;
|
||||
const className = getSingleClassSelector(win.targetSelector);
|
||||
if (className && (classUsage.get(className) || 0) < 2) continue;
|
||||
findings.push({
|
||||
code: "unscoped_gsap_selector",
|
||||
severity: "warning",
|
||||
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
|
||||
selector: win.targetSelector,
|
||||
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`,
|
||||
snippet: truncateSnippet(win.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// gsap_css_transform_conflict
|
||||
({ styles, scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const cssTranslateSelectors = new Map<string, string>();
|
||||
const cssScaleSelectors = new Map<string, string>();
|
||||
|
||||
for (const style of styles) {
|
||||
for (const [, selector, body] of style.content.matchAll(
|
||||
/([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g,
|
||||
)) {
|
||||
const tMatch = body?.match(/transform\s*:\s*([^;]+)/);
|
||||
if (!tMatch || !tMatch[1]) continue;
|
||||
const transformVal = tMatch[1].trim();
|
||||
if (/translate/i.test(transformVal))
|
||||
cssTranslateSelectors.set((selector ?? "").trim(), transformVal);
|
||||
if (/scale/i.test(transformVal))
|
||||
cssScaleSelectors.set((selector ?? "").trim(), transformVal);
|
||||
}
|
||||
}
|
||||
|
||||
if (cssTranslateSelectors.size === 0 && cssScaleSelectors.size === 0) return findings;
|
||||
|
||||
for (const script of scripts) {
|
||||
if (!/gsap\.timeline/.test(script.content)) continue;
|
||||
const windows = extractGsapWindows(script.content);
|
||||
|
||||
type Conflict = { cssTransform: string; props: Set<string>; raw: string };
|
||||
const conflicts = new Map<string, Conflict>();
|
||||
|
||||
for (const win of windows) {
|
||||
if (win.method === "fromTo") continue;
|
||||
const sel = win.targetSelector;
|
||||
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
|
||||
const translateProps = win.properties.filter((p) =>
|
||||
["x", "y", "xPercent", "yPercent"].includes(p),
|
||||
);
|
||||
const scaleProps = win.properties.filter((p) => p === "scale");
|
||||
const cssFromTranslate =
|
||||
translateProps.length > 0 ? cssTranslateSelectors.get(cssKey) : undefined;
|
||||
const cssFromScale = scaleProps.length > 0 ? cssScaleSelectors.get(cssKey) : undefined;
|
||||
if (!cssFromTranslate && !cssFromScale) continue;
|
||||
const existing = conflicts.get(sel) ?? {
|
||||
cssTransform: [cssFromTranslate, cssFromScale].filter(Boolean).join(" "),
|
||||
props: new Set<string>(),
|
||||
raw: win.raw,
|
||||
};
|
||||
for (const p of [...translateProps, ...scaleProps]) existing.props.add(p);
|
||||
conflicts.set(sel, existing);
|
||||
}
|
||||
|
||||
for (const [sel, { cssTransform, props, raw }] of conflicts) {
|
||||
const propList = [...props].join("/");
|
||||
findings.push({
|
||||
code: "gsap_css_transform_conflict",
|
||||
severity: "warning",
|
||||
message:
|
||||
`"${sel}" has CSS \`transform: ${cssTransform}\` and a GSAP tween animates ` +
|
||||
`${propList}. GSAP will overwrite the full CSS transform, discarding any ` +
|
||||
`translateX(-50%) centering or CSS scale value.`,
|
||||
selector: sel,
|
||||
fixHint:
|
||||
`Remove the transform from CSS and use tl.fromTo('${sel}', ` +
|
||||
`{ xPercent: -50, x: -1000 }, { xPercent: -50, x: 0 }) so GSAP owns ` +
|
||||
`the full transform state. tl.fromTo is exempt from this rule.`,
|
||||
snippet: truncateSnippet(raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// missing_gsap_script
|
||||
({ scripts }) => {
|
||||
const allScriptTexts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
|
||||
const allScriptSrcs = scripts
|
||||
.map((s) => readAttr(`<script ${s.attrs}>`, "src") || "")
|
||||
.filter(Boolean);
|
||||
|
||||
const usesGsap = allScriptTexts.some((t) =>
|
||||
/gsap\.(to|from|fromTo|timeline|set|registerPlugin)\b/.test(t),
|
||||
);
|
||||
const hasGsapScript = allScriptSrcs.some((src) => /gsap/i.test(src));
|
||||
|
||||
if (!usesGsap || hasGsapScript) return [];
|
||||
return [
|
||||
{
|
||||
code: "missing_gsap_script",
|
||||
severity: "error",
|
||||
message: "Composition uses GSAP but no GSAP script is loaded. The animation will not run.",
|
||||
fixHint:
|
||||
'Add <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script> before your animation script.',
|
||||
},
|
||||
];
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import { readAttr, truncateSnippet, isMediaTag } from "../utils";
|
||||
|
||||
export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// duplicate_media_id + duplicate_media_discovery_risk
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const mediaById = new Map<string, typeof tags>();
|
||||
const mediaFingerprintCounts = new Map<string, number>();
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!isMediaTag(tag.name)) continue;
|
||||
const elementId = readAttr(tag.raw, "id");
|
||||
if (elementId) {
|
||||
const existing = mediaById.get(elementId) || [];
|
||||
existing.push(tag);
|
||||
mediaById.set(elementId, existing);
|
||||
}
|
||||
const fingerprint = [
|
||||
tag.name,
|
||||
readAttr(tag.raw, "src") || "",
|
||||
readAttr(tag.raw, "data-start") || "",
|
||||
readAttr(tag.raw, "data-duration") || "",
|
||||
].join("|");
|
||||
mediaFingerprintCounts.set(fingerprint, (mediaFingerprintCounts.get(fingerprint) || 0) + 1);
|
||||
}
|
||||
|
||||
for (const [elementId, mediaTags] of mediaById) {
|
||||
if (mediaTags.length < 2) continue;
|
||||
findings.push({
|
||||
code: "duplicate_media_id",
|
||||
severity: "error",
|
||||
message: `Media id "${elementId}" is defined multiple times.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
"Give each media element a unique id so preview and producer discover the same media graph.",
|
||||
snippet: truncateSnippet(mediaTags[0]?.raw || ""),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [fingerprint, count] of mediaFingerprintCounts) {
|
||||
if (count < 2) continue;
|
||||
const [tagName, src, dataStart, dataDuration] = fingerprint.split("|");
|
||||
findings.push({
|
||||
code: "duplicate_media_discovery_risk",
|
||||
severity: "warning",
|
||||
message: `Detected ${count} matching ${tagName} entries with the same source/start/duration.`,
|
||||
fixHint: "Avoid duplicated media nodes that can be discovered twice during compilation.",
|
||||
snippet: truncateSnippet(
|
||||
`${tagName} src=${src} data-start=${dataStart} data-duration=${dataDuration}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// video_missing_muted
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
if (tag.name !== "video") continue;
|
||||
const hasMuted = /\bmuted\b/i.test(tag.raw);
|
||||
if (!hasMuted && readAttr(tag.raw, "data-start")) {
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "video_missing_muted",
|
||||
severity: "error",
|
||||
message: `<video${elementId ? ` id="${elementId}"` : ""}> has data-start but is not muted. The framework expects video to be muted with a separate <audio> element for sound.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
"Add the `muted` attribute to the <video> tag and use a separate <audio> element with the same src for audio playback.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// video_nested_in_timed_element
|
||||
({ source, tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const timedTagPositions: Array<{ name: string; start: number; id?: string }> = [];
|
||||
for (const tag of tags) {
|
||||
if (tag.name === "video" || tag.name === "audio") continue;
|
||||
if (readAttr(tag.raw, "data-start")) {
|
||||
timedTagPositions.push({
|
||||
name: tag.name,
|
||||
start: tag.index,
|
||||
id: readAttr(tag.raw, "id") || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const tag of tags) {
|
||||
if (tag.name !== "video") continue;
|
||||
if (!readAttr(tag.raw, "data-start")) continue;
|
||||
for (const parent of timedTagPositions) {
|
||||
if (parent.start < tag.index) {
|
||||
const parentClosePattern = new RegExp(`</${parent.name}>`, "gi");
|
||||
const between = source.substring(parent.start, tag.index);
|
||||
if (!parentClosePattern.test(between)) {
|
||||
findings.push({
|
||||
code: "video_nested_in_timed_element",
|
||||
severity: "error",
|
||||
message: `<video> with data-start is nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. The framework cannot manage playback of nested media — video will be FROZEN in renders.`,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint:
|
||||
"Move the <video> to be a direct child of the stage, or remove data-start from the wrapper div (use it as a non-timed visual container).",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// self_closing_media_tag
|
||||
({ source }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const selfClosingMediaRe = /<(audio|video)\b[^>]*\/>/gi;
|
||||
let scMatch: RegExpExecArray | null;
|
||||
while ((scMatch = selfClosingMediaRe.exec(source)) !== null) {
|
||||
const tagName = scMatch[1] || "audio";
|
||||
const elementId = readAttr(scMatch[0], "id") || undefined;
|
||||
findings.push({
|
||||
code: "self_closing_media_tag",
|
||||
severity: "error",
|
||||
message: `Self-closing <${tagName}/> is invalid HTML. The browser will leave the tag open, swallowing all subsequent elements as invisible fallback content. This makes compositions INVISIBLE.`,
|
||||
elementId,
|
||||
fixHint: `Change <${tagName} .../> to <${tagName} ...></${tagName}> — media elements MUST have explicit closing tags.`,
|
||||
snippet: truncateSnippet(scMatch[0]),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// placeholder_media_url
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const PLACEHOLDER_DOMAINS =
|
||||
/\b(placehold\.co|placeholder\.com|placekitten\.com|picsum\.photos|example\.com|via\.placeholder\.com|dummyimage\.com)\b/i;
|
||||
for (const tag of tags) {
|
||||
if (!isMediaTag(tag.name)) continue;
|
||||
const src = readAttr(tag.raw, "src");
|
||||
if (!src) continue;
|
||||
if (PLACEHOLDER_DOMAINS.test(src)) {
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "placeholder_media_url",
|
||||
severity: "error",
|
||||
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses a placeholder URL that will 404 at render time: ${src.slice(0, 80)}`,
|
||||
elementId,
|
||||
fixHint: "Replace with a real media URL. Placeholder domains will 404 at render time.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// base64_media_prohibited
|
||||
({ source }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const base64MediaRe =
|
||||
/src\s*=\s*["'](data:(?:audio|video)\/[^;]+;base64,([A-Za-z0-9+/=]{20,}))["']/gi;
|
||||
let b64Match: RegExpExecArray | null;
|
||||
while ((b64Match = base64MediaRe.exec(source)) !== null) {
|
||||
const sample = (b64Match[2] || "").slice(0, 200);
|
||||
const uniqueChars = new Set(sample.replace(/[A-Za-z0-9+/=]/g, (c) => c)).size;
|
||||
const dataSize = Math.round(((b64Match[2] || "").length * 3) / 4);
|
||||
const isSuspicious = uniqueChars < 15 || (dataSize > 1000 && dataSize < 50000);
|
||||
findings.push({
|
||||
code: "base64_media_prohibited",
|
||||
severity: "error",
|
||||
message: `Inline base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB)${isSuspicious ? " — likely fabricated data" : ""}. Base64 media is prohibited — it bloats file size and breaks rendering.`,
|
||||
fixHint:
|
||||
"Use a relative path (assets/music.mp3) or HTTPS URL for the audio/video src. Never embed media as base64.",
|
||||
snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "..."),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// media_missing_id + media_missing_src + media_preload_none
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
if (tag.name !== "video" && tag.name !== "audio") continue;
|
||||
const hasDataStart = readAttr(tag.raw, "data-start");
|
||||
const hasId = readAttr(tag.raw, "id");
|
||||
const hasSrc = readAttr(tag.raw, "src");
|
||||
if (hasDataStart && !hasId) {
|
||||
findings.push({
|
||||
code: "media_missing_id",
|
||||
severity: "error",
|
||||
message: `<${tag.name}> has data-start but no id attribute. The renderer requires id to discover media elements — this ${tag.name === "audio" ? "audio will be SILENT" : "video will be FROZEN"} in renders.`,
|
||||
fixHint: `Add a unique id attribute: <${tag.name} id="my-${tag.name}" ...>`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
if (hasDataStart && hasId && !hasSrc) {
|
||||
findings.push({
|
||||
code: "media_missing_src",
|
||||
severity: "error",
|
||||
message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
|
||||
elementId: hasId,
|
||||
fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
if (readAttr(tag.raw, "preload") === "none") {
|
||||
findings.push({
|
||||
code: "media_preload_none",
|
||||
severity: "warning",
|
||||
message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has preload="none" which prevents the renderer from loading this media. The compiler strips it for renders, but preview may also have issues.`,
|
||||
elementId: hasId || undefined,
|
||||
fixHint: `Remove preload="none" or change to preload="auto". The framework manages media loading.`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
@@ -22,3 +22,7 @@ export type HyperframeLintResult = {
|
||||
export type HyperframeLinterOptions = {
|
||||
filePath?: string;
|
||||
};
|
||||
|
||||
// A rule is a pure function: receives parsed context, returns zero or more findings.
|
||||
// Rule modules should receive a LintContext (defined in ./context) as the type parameter.
|
||||
export type LintRule<TContext> = (ctx: TContext) => HyperframeLintFinding[];
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Shared types, regex constants, and utility functions used across lint rule modules.
|
||||
// Nothing in this file should emit findings — it only parses and extracts.
|
||||
|
||||
export type OpenTag = {
|
||||
raw: string;
|
||||
name: string;
|
||||
attrs: string;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type ExtractedBlock = {
|
||||
attrs: string;
|
||||
content: string;
|
||||
raw: string;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export const TAG_PATTERN = /<([a-z][\w:-]*)(\s[^<>]*?)?>/gi;
|
||||
export const STYLE_BLOCK_PATTERN = /<style\b([^>]*)>([\s\S]*?)<\/style>/gi;
|
||||
export const SCRIPT_BLOCK_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
||||
export const COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
|
||||
export const TIMELINE_REGISTRY_INIT_PATTERN =
|
||||
/window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
|
||||
export const TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines\[[^\]]+\]\s*=/i;
|
||||
export const WINDOW_TIMELINE_ASSIGN_PATTERN =
|
||||
/window\.__timelines\[\s*["']([^"']+)["']\s*\]\s*=\s*([A-Za-z_$][\w$]*)/i;
|
||||
export const INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
|
||||
|
||||
export function extractOpenTags(source: string): OpenTag[] {
|
||||
const tags: OpenTag[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
const pattern = new RegExp(TAG_PATTERN.source, TAG_PATTERN.flags);
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
const raw = match[0];
|
||||
if (raw.startsWith("</") || raw.startsWith("<!")) continue;
|
||||
tags.push({
|
||||
raw,
|
||||
name: (match[1] || "").toLowerCase(),
|
||||
attrs: match[2] || "",
|
||||
index: match.index,
|
||||
});
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function extractBlocks(source: string, pattern: RegExp): ExtractedBlock[] {
|
||||
const blocks: ExtractedBlock[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
const p = new RegExp(pattern.source, pattern.flags);
|
||||
while ((match = p.exec(source)) !== null) {
|
||||
blocks.push({
|
||||
attrs: match[1] || "",
|
||||
content: match[2] || "",
|
||||
raw: match[0],
|
||||
index: match.index,
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function findRootTag(source: string): OpenTag | null {
|
||||
const bodyMatch = source.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
|
||||
const bodyContent = bodyMatch ? (bodyMatch[1] ?? source) : source;
|
||||
const bodyTags = extractOpenTags(bodyContent);
|
||||
for (const tag of bodyTags) {
|
||||
if (["script", "style", "meta", "link", "title"].includes(tag.name)) continue;
|
||||
return tag;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readAttr(tagSource: string, attr: string): string | null {
|
||||
if (!tagSource) return null;
|
||||
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = tagSource.match(new RegExp(`\\b${escaped}\\s*=\\s*["']([^"']+)["']`, "i"));
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
||||
export function collectCompositionIds(tags: OpenTag[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const tag of tags) {
|
||||
const compId = readAttr(tag.raw, "data-composition-id");
|
||||
if (compId) ids.add(compId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function extractCompositionIdsFromCss(css: string): string[] {
|
||||
const ids = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
const pattern = new RegExp(
|
||||
COMPOSITION_ID_IN_CSS_PATTERN.source,
|
||||
COMPOSITION_ID_IN_CSS_PATTERN.flags,
|
||||
);
|
||||
while ((match = pattern.exec(css)) !== null) {
|
||||
if (match[1]) ids.add(match[1]);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export function getInlineScriptSyntaxError(source: string): string | null {
|
||||
if (!source.trim()) return null;
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function(source);
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function isMediaTag(tagName: string): boolean {
|
||||
return tagName === "video" || tagName === "audio" || tagName === "img";
|
||||
}
|
||||
|
||||
export function truncateSnippet(value: string, maxLength = 220): string | undefined {
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return undefined;
|
||||
if (normalized.length <= maxLength) return normalized;
|
||||
return `${normalized.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
Reference in New Issue
Block a user