refactor: extract @hyperframes/lint from core (#1756)

* refactor: extract @hyperframes/lint package from core

Moves all lint rules, hyperframeLinter, lintProject, and related types
from packages/core/src/lint/ into a new standalone packages/lint package.

Core keeps a thin re-export stub at @hyperframes/core/lint for backward
compatibility. Consumer imports (cli lint command, producer hyperframeLint)
are updated to import from @hyperframes/lint directly.

Depends on @hyperframes/parsers (PR #1755).

* fix: restore postcss-selector-parser in core (sourceMutation.ts still uses it)

* fix(ci): add parsers+lint to Dockerfile and build before preview tests

* chore: update bun.lock after restoring postcss-selector-parser dep

* test(cli): update lintProject test for string-dir signature from @hyperframes/lint

* refactor(core): single-source the lint engine in @hyperframes/lint

Delete core's byte-identical copy of the lint rule engine and re-point
staticGuard at @hyperframes/lint, so the render-time render-gate and the
studio preview share one rule engine instead of two copies that could
silently diverge. Back-compat preserved via the @hyperframes/core/lint stub.

Addresses review feedback on the dual-copy footgun.
This commit is contained in:
Miguel Ángel
2026-06-27 01:16:40 -04:00
committed by GitHub
parent cdf9c817e1
commit 98d0bdd73c
53 changed files with 745 additions and 656 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
import { lintHyperframeHtml } from "../lint/hyperframeLinter";
import { lintHyperframeHtml } from "@hyperframes/lint";
export type HyperframeStaticFailureReason =
| "missing_composition_id"
+6 -2
View File
@@ -128,8 +128,12 @@ describe("@hyperframes/core public API exports", () => {
});
describe("lint exports", () => {
it("exports lintHyperframeHtml", () => {
expect(typeof core.lintHyperframeHtml).toBe("function");
it("exposes lintHyperframeHtml via the @hyperframes/core/lint back-compat stub", async () => {
// Lint moved to @hyperframes/lint; core's main entry no longer re-exports
// it (that would cycle through the lint package). The subpath stub keeps
// existing @hyperframes/core/lint imports working.
const lint = await import("./lint/index.js");
expect(typeof lint.lintHyperframeHtml).toBe("function");
});
});
+5 -8
View File
@@ -138,18 +138,15 @@ export {
MEDIA_DURATION_CLAMP_EPSILON_SECONDS,
} from "./compiler/timingCompiler";
// Lint
export type {
HyperframeLintSeverity,
HyperframeLintFinding,
HyperframeLintResult,
HyperframeLinterOptions,
} from "./lint/types";
export { lintHyperframeHtml } from "./lint/hyperframeLinter";
// Lint moved to @hyperframes/lint. Import lint APIs from @hyperframes/lint
// directly, or via the back-compat stub at @hyperframes/core/lint. Not
// re-exported here — doing so would cycle core's main entry through the lint
// package (which imports core utilities back).
export {
rewriteAssetPaths,
rewriteAssetPath,
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "./compiler/rewriteSubCompPaths";
export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPaths";
export { queryByAttr } from "./utils/cssSelector";
-66
View File
@@ -1,66 +0,0 @@
import type { HyperframeLintFinding, HyperframeLinterOptions } from "./types";
import {
extractBlocks,
extractOpenTags,
findRootTag,
collectCompositionIds,
readAttr,
stripHtmlComments,
STYLE_BLOCK_PATTERN,
SCRIPT_BLOCK_PATTERN,
} from "./utils";
import type { OpenTag, ExtractedBlock } from "./utils";
export type { OpenTag, ExtractedBlock };
export type LintContext = {
source: string;
rawSource: 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 {
const rawSource = html || "";
// Strip HTML comments before scanning so a commented-out <template> or tag can't
// hijack the boundary match below. Linear + fixpoint (see stripHtmlComments) to
// stay ReDoS-free and catch markers that re-form when a comment is removed.
let source = stripHtmlComments(rawSource);
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),
...(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);
const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id");
return {
source,
rawSource,
tags,
styles,
scripts,
compositionIds,
rootTag,
rootCompositionId,
options,
};
}
@@ -1,124 +0,0 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "./hyperframeLinter.js";
describe("lintHyperframeHtml — orchestrator", () => {
const validComposition = `
<html>
<body>
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080" data-start="0">
<div id="stage"></div>
</div>
<script src="https://cdn.gsap.com/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["comp-1"] = tl;
</script>
</body>
</html>`;
it("reports no errors for a valid composition", async () => {
const result = await lintHyperframeHtml(validComposition);
expect(result.ok).toBe(true);
expect(result.errorCount).toBe(0);
});
it("attaches filePath to findings when option is set", async () => {
const html = "<html><body><div></div></body></html>";
const result = await lintHyperframeHtml(html, { filePath: "test.html" });
for (const finding of result.findings) {
expect(finding.file).toBe("test.html");
}
});
it("deduplicates identical findings", async () => {
const html = `
<html><body>
<div id="root"></div>
<script>const tl = gsap.timeline();</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const codes = result.findings.map((f) => `${f.code}|${f.message}`);
const uniqueCodes = [...new Set(codes)];
expect(codes.length).toBe(uniqueCodes.length);
});
it("strips <template> wrapper before linting composition files", async () => {
const html = `<template id="my-comp-template">
<div data-composition-id="my-comp" data-width="1920" data-height="1080"
style="position:relative;width:1920px;height:1080px;">
<div id="stage"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["my-comp"] = tl;
</script>
</template>`;
const result = await lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
const missing = result.findings.filter(
(f) => f.code === "missing-composition-id" || f.code === "missing-dimensions",
);
expect(missing).toHaveLength(0);
});
it("ignores comments that mention template tags before the real template", async () => {
const html = `<!doctype html>
<html>
<head>
<!-- Authoring note: styles and scripts live inside <template>. -->
</head>
<body>
<template id="my-comp-template">
<style>#root { width: 1920px; height: 1080px; }</style>
<div data-composition-id="my-comp" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["my-comp"] = tl;
</script>
</template>
</body>
</html>`;
const result = await lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
const rootFindings = result.findings.filter(
(f) => f.code === "root_missing_composition_id" || f.code === "root_missing_dimensions",
);
expect(rootFindings).toHaveLength(0);
});
it("strips comments whose markers re-form after one pass (no decoy template survives)", async () => {
// Adjacent comment markers: removing the inner `<!-- -->` in a single pass
// re-joins `<` + `!-- … -->` into a fresh, complete `<!-- … -->` that a lone
// global replace leaves behind — surfacing a decoy <template> with no
// composition-id. A fixpoint strip removes it; this guards that behavior.
const html = `<!doctype html>
<html>
<body>
<<!-- -->!-- <template id="decoy-template"></template> -->
<template id="my-comp-template">
<style>#root { width: 1920px; height: 1080px; }</style>
<div data-composition-id="my-comp" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["my-comp"] = tl;
</script>
</template>
</body>
</html>`;
const result = await lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
const rootFindings = result.findings.filter(
(f) => f.code === "root_missing_composition_id" || f.code === "root_missing_dimensions",
);
expect(rootFindings).toHaveLength(0);
});
});
-155
View File
@@ -1,155 +0,0 @@
import type { HyperframeLintFinding, HyperframeLintResult, HyperframeLinterOptions } from "./types";
import { buildLintContext } from "./context";
import { readAttr, truncateSnippet } from "./utils";
import { coreRules } from "./rules/core";
import { mediaRules } from "./rules/media";
import { gsapRules } from "./rules/gsap";
import { captionRules } from "./rules/captions";
import { compositionRules } from "./rules/composition";
import { adapterRules } from "./rules/adapters";
import { textureRules } from "./rules/textures";
import { fontRules } from "./rules/fonts";
import { slideshowRules } from "./rules/slideshow";
const ALL_RULES = [
...coreRules,
...mediaRules,
...gsapRules,
...captionRules,
...compositionRules,
...adapterRules,
...textureRules,
...fontRules,
...slideshowRules,
];
export async function lintHyperframeHtml(
html: string,
options: HyperframeLinterOptions = {},
): Promise<HyperframeLintResult> {
const ctx = buildLintContext(html, options);
const findings: HyperframeLintFinding[] = [];
const seen = new Set<string>();
for (const rule of ALL_RULES) {
for (const finding of await Promise.resolve(rule(ctx))) {
const dedupeKey = [
finding.code,
finding.severity,
finding.selector || "",
finding.elementId || "",
finding.message,
].join("|");
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
findings.push(options.filePath ? { ...finding, file: options.filePath } : finding);
}
}
const errorCount = findings.filter((f) => f.severity === "error").length;
const warningCount = findings.filter((f) => f.severity === "warning").length;
const infoCount = findings.filter((f) => f.severity === "info").length;
return {
ok: errorCount === 0,
errorCount,
warningCount,
infoCount,
findings,
};
}
// ── Async media URL accessibility checker ─────────────────────────────────
function extractMediaUrls(html: string): Array<{
url: string;
tagName: string;
elementId?: string;
snippet: string;
}> {
const results: Array<{
url: string;
tagName: string;
elementId?: string;
snippet: string;
}> = [];
const tagRe = /<(video|audio|img|source)\b[^>]*>/gi;
let match: RegExpExecArray | null;
while ((match = tagRe.exec(html)) !== null) {
const tagName = (match[1] ?? "").toLowerCase();
const raw = match[0];
const src = readAttr(raw, "src");
if (!src) continue;
if (/^https?:\/\//i.test(src)) {
results.push({
url: src,
tagName,
elementId: readAttr(raw, "id") || undefined,
snippet: truncateSnippet(raw) ?? "",
});
}
}
return results;
}
/**
* Async lint pass: HEAD-checks every remote media URL in the HTML.
* Returns findings for URLs that are unreachable (non-2xx status or network error).
*
* Call this after `lintHyperframeHtml()` and merge the findings.
*
* @param timeoutMs - per-request timeout (default 8000ms)
*/
export async function lintMediaUrls(
html: string,
options: { timeoutMs?: number } = {},
): Promise<HyperframeLintFinding[]> {
const urls = extractMediaUrls(html);
if (urls.length === 0) return [];
const timeout = options.timeoutMs ?? 8000;
const findings: HyperframeLintFinding[] = [];
const seen = new Set<string>();
const unique = urls.filter((u) => {
if (seen.has(u.url)) return false;
seen.add(u.url);
return true;
});
const checks = unique.map(async ({ url, tagName, elementId, snippet }) => {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const resp = await fetch(url, {
method: "HEAD",
signal: controller.signal,
redirect: "follow",
});
clearTimeout(timer);
if (!resp.ok) {
findings.push({
code: "inaccessible_media_url",
severity: "error",
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 100)}`,
elementId,
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
snippet,
});
}
} catch (err) {
const reason = err instanceof Error ? err.name : "unknown";
findings.push({
code: "inaccessible_media_url",
severity: "error",
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references an unreachable URL (${reason}): ${url.slice(0, 100)}`,
elementId,
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
snippet,
});
}
});
await Promise.all(checks);
return findings;
}
+2 -7
View File
@@ -1,7 +1,2 @@
export type {
HyperframeLintSeverity,
HyperframeLintFinding,
HyperframeLintResult,
HyperframeLinterOptions,
} from "./types";
export { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter";
/** @deprecated Import from @hyperframes/lint */
export * from "@hyperframes/lint";
@@ -1,144 +0,0 @@
// fallow-ignore-file code-duplication
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("adapter rules", () => {
it("reports error when GSAP is used without a GSAP script tag", async () => {
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 = await 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", async () => {
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 = await 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", async () => {
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 = await 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", async () => {
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 = await 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", async () => {
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 = await 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", async () => {
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 = await 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", async () => {
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 = await 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", async () => {
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 = await lintHyperframeHtml(html);
const adapterFindings = result.findings.filter((f) =>
["missing_gsap_script", "missing_lottie_script", "missing_three_script"].includes(f.code),
);
expect(adapterFindings).toHaveLength(0);
});
});
-55
View File
@@ -1,55 +0,0 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import { readAttr, extractScriptTextsAndSrcs } from "../utils";
export const adapterRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// missing_lottie_script
({ tags, scripts }) => {
const { texts, srcs } = extractScriptTextsAndSrcs(scripts);
const hasLottieAttr = tags.some((t) => readAttr(t.raw, "data-lottie-src") !== null);
const usesLottieApi = texts.some((t) =>
/lottie\.(loadAnimation|setSpeed|play|stop|destroy)\b/.test(t),
);
const hasLottieScript = srcs.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 { texts, srcs } = extractScriptTextsAndSrcs(scripts);
const usesThree = texts.some((t) => /\bTHREE\./.test(t));
const hasThreeScript = srcs.some((src) => /three/i.test(src));
const hasThreeImportMap = texts.some(
(t) =>
/["']three["']/.test(t) &&
/importmap/.test(scripts.find((s) => s.content === t)?.attrs || ""),
);
const hasThreeModuleImport = texts.some(
(t) => /\bimport\b.*['"]three['"]/.test(t) || /\bfrom\s+['"]three['"]/.test(t),
);
if (!usesThree || hasThreeScript || hasThreeImportMap || hasThreeModuleImport) 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.',
},
];
},
];
@@ -1,166 +0,0 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("caption rules", () => {
it("warns when caption exit has no hard kill tl.set", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<div id="caption-container"></div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function(group, gi) {
var groupEl = document.createElement("div");
groupEl.id = "cg-" + gi;
tl.set(groupEl, { opacity: 1 }, group.start);
tl.to(groupEl, { opacity: 0, duration: 0.12 }, group.end - 0.12);
});
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not warn when caption exit has hard kill tl.set", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<div id="caption-container"></div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function(group, gi) {
var groupEl = document.createElement("div");
groupEl.id = "cg-" + gi;
tl.set(groupEl, { opacity: 1 }, group.start);
tl.to(groupEl, { opacity: 0, duration: 0.12 }, group.end - 0.12);
tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end);
});
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("does not warn for generic GSAP opacity exits in non-caption loops", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
var sceneCaption = document.querySelector("#scene-caption");
CARDS.forEach(function(group, gi) {
var groupEl = document.createElement("div");
groupEl.id = "card-" + gi;
tl.to(groupEl, { opacity: 0, duration: 0.12 }, 2);
});
window.__timelines["main"] = tl;
</script>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("does not warn on a content frame that only mentions karaoke in a comment", async () => {
const html = `<template id="06-one-platform-template">
<div id="root" data-composition-id="06-one-platform" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// "Minutes, not weeks" lands with a karaoke-style keyword glow
SCREENS.forEach(function (s, i) {
var el = document.getElementById("screen-" + i);
tl.to(el, { y: -40, opacity: 0, duration: 0.3 }, i * 1.3);
});
window.__timelines["06-one-platform"] = tl;
</script>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("warns when caption group has nowrap without max-width", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: absolute;
white-space: nowrap;
text-align: center;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_text_overflow_risk");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not warn when caption group has nowrap with max-width", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: absolute;
white-space: nowrap;
max-width: 1600px;
overflow: hidden;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "caption_text_overflow_risk" && f.severity === "warning",
);
expect(finding).toBeUndefined();
});
it("warns when caption container uses position: relative", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: relative;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_container_relative_position");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
});
-271
View File
@@ -1,271 +0,0 @@
import type { LintContext, HyperframeLintFinding } from "../context";
/** Extract a bracket-balanced array literal starting at the `[` found by `varMatch`. */
// fallow-ignore-next-line complexity
function extractArrayLiteral(src: string, varMatch: RegExpExecArray): string | null {
const openIdx = varMatch.index + varMatch[0].length - 1;
let depth = 0;
let inStr = false;
let strChar = "";
for (let i = openIdx; i < src.length; i++) {
const c = src[i]!;
if (inStr) {
if (c === "\\") {
i++;
continue;
}
if (c === strChar) inStr = false;
} else if (c === '"' || c === "'") {
inStr = true;
strChar = c;
} else if (c === "[") {
depth++;
} else if (c === "]") {
depth--;
if (depth === 0) return src.slice(openIdx, i + 1);
}
}
return null;
}
export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// caption_exit_missing_hard_kill
({ scripts, styles, options, rootCompositionId }) => {
const findings: HyperframeLintFinding[] = [];
// Only the ACTUAL captions composition. A content frame that merely mentions
// "karaoke" / "caption-*" in a comment (or uses an unrelated forEach + opacity:0
// screen-swap) is NOT captions — gating here prevents the false positive that fired
// on a content frame whose only caption signal was a descriptive comment.
const isCaptionComposition =
Boolean(options.filePath && /caption/i.test(options.filePath)) ||
rootCompositionId === "captions" ||
styles.some((s) => /\.caption[-_]?(?:group|word|line|block)\b|\.cg-/.test(s.content));
if (!isCaptionComposition) return findings;
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) &&
/karaoke|caption[-_]?(?:group|word|line|block)|cg-/.test(content);
if (hasCaptionLoop && hasExitTween && !hasHardKill) {
findings.push({
code: "caption_exit_missing_hard_kill",
severity: "error",
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_transcript_not_inline
// fallow-ignore-next-line complexity
({ scripts, styles, options }) => {
const findings: HyperframeLintFinding[] = [];
// Only check files that look like caption compositions
const isCaptionFile =
(options.filePath && /caption/i.test(options.filePath)) ||
styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
if (!isCaptionFile) return findings;
const allScript = scripts.map((s) => s.content).join("\n");
const hasInlineTranscript = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.test(
allScript,
);
const hasFetchTranscript = /fetch\s*\(\s*["'][^"']*transcript/i.test(allScript);
if (!hasInlineTranscript && hasFetchTranscript) {
findings.push({
code: "caption_transcript_not_inline",
severity: "error",
message:
"Captions composition loads transcript via fetch(). The studio caption editor " +
"requires an inline `var TRANSCRIPT = [...]` array to detect and edit captions.",
fixHint:
'Embed the transcript as `var TRANSCRIPT = [{ "text": "...", "start": 0, "end": 1 }, ...]` ' +
"with JSON-quoted property keys. See the captions skill for details.",
});
}
if (hasInlineTranscript) {
// Verify the inline transcript can be parsed.
// Use a balanced-bracket scan instead of a regex to correctly handle
// nested arrays (e.g. word-level timing arrays inside each entry).
const varStart = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.exec(allScript);
const transcriptJson = varStart ? extractArrayLiteral(allScript, varStart) : null;
if (transcriptJson) {
try {
JSON.parse(transcriptJson);
} catch {
findings.push({
code: "caption_transcript_parse_error",
severity: "error",
message:
"Inline TRANSCRIPT array is not valid JSON. The studio caption editor may fail " +
"to parse it. Common cause: unquoted property keys with apostrophes in text.",
fixHint:
'Use JSON-quoted keys: { "text": "don\'t", "start": 0, "end": 1 } instead of ' +
'{ text: "don\'t", start: 0, end: 1 }.',
});
}
}
}
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: "error",
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;
},
// caption_overflow_clips_scaled_words
({ styles, scripts }) => {
const findings: HyperframeLintFinding[] = [];
const hasScaledWords = scripts.some(
(s) => /scale\s*:\s*1\.[2-9]/.test(s.content) && /caption|word|cg-/.test(s.content),
);
if (!hasScaledWords) return findings;
for (const style of styles) {
const captionBlocks = style.content.matchAll(
/(\.caption[-_]?(?:group|container)|#caption[-_]?(?:layer|container))\s*\{([^}]+)\}/gi,
);
for (const [, selector, body] of captionBlocks) {
if (!body) continue;
if (/overflow\s*:\s*hidden/i.test(body)) {
findings.push({
code: "caption_overflow_clips_scaled_words",
severity: "error",
selector: (selector ?? "").trim(),
message: `"${(selector ?? "").trim()}" has overflow: hidden but GSAP scales caption words above 1.0x. Scaled emphasis words and their glow effects will be clipped.`,
fixHint:
"Use overflow: visible on caption containers. Rely on fitTextFontSize with reduced maxWidth to prevent overflow instead.",
});
}
}
}
return findings;
},
// caption_textshadow_on_group_container
({ scripts, styles }) => {
const findings: HyperframeLintFinding[] = [];
const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
if (!isCaptionFile) return findings;
for (const script of scripts) {
// Detect textShadow tweened on a group container (div with child word spans)
const groupShadowPattern =
/\.to\s*\(\s*(?:div|groupEl|el|captionEl|document\.getElementById\s*\(\s*["']cg-)\s*[^,]*,\s*\{[^}]*textShadow/g;
// Also catch selector-based targeting of group containers
const selectorShadowPattern =
/\.to\s*\(\s*["'](?:#cg-\d+|\.caption[-_]?group)["']\s*,\s*\{[^}]*textShadow/g;
if (groupShadowPattern.test(script.content) || selectorShadowPattern.test(script.content)) {
findings.push({
code: "caption_textshadow_on_group_container",
severity: "warning",
message:
"textShadow is tweened on a caption group container. When children have semi-transparent " +
"color (e.g., inactive karaoke words at rgba opacity), the glow renders as a visible " +
"rectangle behind the entire group.",
fixHint:
"Apply textShadow to individual active word elements instead of the group container. " +
"Use scale on the group for bass-reactive pulsing.",
});
}
}
return findings;
},
// caption_fittext_scale_mismatch
// fallow-ignore-next-line complexity
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const content = script.content;
const fitTextMatch = content.match(/fitTextFontSize\s*\([^)]*maxWidth\s*:\s*(\d+)/);
if (!fitTextMatch) continue;
const maxWidth = parseInt(fitTextMatch[1] ?? "0", 10);
if (!maxWidth) continue;
// Find max scale on caption words
const scaleMatches = [...content.matchAll(/scale\s*:\s*(1\.\d+)/g)];
const captionContext = /caption|word|cg-|karaoke/i.test(content);
if (!captionContext || scaleMatches.length === 0) continue;
let maxScale = 1;
for (const m of scaleMatches) {
const val = parseFloat(m[1] ?? "1");
if (val > maxScale) maxScale = val;
}
// Check if maxWidth * maxScale exceeds safe bounds (1920 - reasonable margins)
const effectiveWidth = maxWidth * maxScale;
if (effectiveWidth > 1760) {
findings.push({
code: "caption_fittext_scale_mismatch",
severity: "warning",
message:
`fitTextFontSize uses maxWidth: ${maxWidth}px but emphasis words scale up to ${maxScale}x. ` +
`Effective width ${Math.round(effectiveWidth)}px may overflow the composition (1920px minus margins).`,
fixHint: `Reduce maxWidth to ${Math.floor(1700 / maxScale)}px to leave headroom for scaled emphasis words.`,
});
}
}
return findings;
},
];
File diff suppressed because it is too large Load Diff
-723
View File
@@ -1,723 +0,0 @@
import type { LintContext, HyperframeLintFinding, ExtractedBlock } from "../context";
import { findHtmlTag, readAttr, readJsonAttr, stripJsComments, truncateSnippet } from "../utils";
import { COMPOSITION_VARIABLE_TYPES } from "../../core.types";
// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
// to inspect and revise reliably in a single composition.
const MAX_COMPOSITION_LINES = 300;
const MAX_TIMED_ELEMENTS_PER_TRACK = 3;
const TRACK_DENSITY_EXEMPT_TAGS = new Set(["audio", "script", "style", "video"]);
function countPhysicalLines(source: string): number {
if (source.length === 0) return 0;
const normalized = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
return withoutFinalNewline.split("\n").length;
}
function countStructuralLines(source: string): number {
return countPhysicalLines(source.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "<style></style>"));
}
export function isRegistrySourceFile(filePath?: string): boolean {
if (!filePath) return false;
const normalized = filePath.replace(/\\/g, "/");
return /(?:^|\/)registry\/blocks\/([^/]+)\/\1\.html$/i.test(normalized);
}
export function isRegistryInstalledFile(rawSource: string): boolean {
return /^\s*<!--\s*hyperframes-registry-item:[^>]*-->/i.test(rawSource.slice(0, 512));
}
function isCompositionRootOrMount(rawTag: string): boolean {
return Boolean(
readAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src"),
);
}
// Asset references inside CSS `url(...)`/`url("...")`/`url('...')` functions.
// Returns the inner path without quotes; comments are stripped first so
// `/* url(foo) */` is ignored. Bare `url()` and `data:` are excluded by the
// rules that consume this — the helper just yields raw URL values.
function extractCssUrlReferences(css: string): string[] {
const out: string[] = [];
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
const urlPattern = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
let m: RegExpExecArray | null;
while ((m = urlPattern.exec(noComments)) !== null) {
const raw = (m[2] ?? "").trim();
if (raw) out.push(raw);
}
return out;
}
// Top-level CSS selectors (comma-split) in a stylesheet, skipping at-rule headers
// (@media/@keyframes/...) and keyframe stops. Heuristic — the lint layer has no
// full CSS parser, and rules elsewhere in this file scan CSS the same way.
function extractCssSelectors(css: string): string[] {
const out: string[] = [];
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
const ruleHeader = /([^{}]+)\{/g;
let m: RegExpExecArray | null;
while ((m = ruleHeader.exec(noComments)) !== null) {
const header = (m[1] ?? "").trim();
if (!header || header.startsWith("@")) continue;
for (const sel of header.split(",")) {
const s = sel.trim();
if (s) out.push(s);
}
}
return out;
}
// Class tokens in a selector's leftmost compound (before the first descendant /
// child / sibling combinator). `.frame .title` → ["frame"]; `.a.b > .c` → ["a","b"].
function leftmostCompoundClasses(selector: string): string[] {
const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
return (leftmost.match(/\.([\w-]+)/g) ?? []).map((c) => c.slice(1));
}
// Distinct selectors across all <style> blocks whose leftmost compound keys off one
// of the root element's own classes — the ones that break under id-scoping.
function rootClassStyledSelectors(styles: ExtractedBlock[], rootClasses: string[]): string[] {
const offenders: string[] = [];
for (const style of styles) {
for (const selector of extractCssSelectors(style.content)) {
const hitsRoot = leftmostCompoundClasses(selector).some((c) => rootClasses.includes(c));
if (hitsRoot && !offenders.includes(selector)) offenders.push(selector);
}
}
return offenders;
}
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// invalid_parent_traversal_in_asset_path — catches `../` traversal in src,
// href, inline-style url(), and <style> url() asset references on
// compositions. Sub-compositions live under compositions/ but are served
// with the project root as their base URL, so any `../`-traversing path
// climbs above the project root and 404s in Studio preview. Renders
// tolerate it because the server-side bundler rewrites `../foo` against
// each sub-composition's source path; the runtime now mirrors that fallback
// (see rewriteSubCompositionAssetPaths in runtime/compositionLoader.ts), but
// the authoring-time signal is still wrong — flag it at lint time so the
// baked path is plain root-relative and matches what the bundler emits.
//
// Mirrors the runtime fallback's surface: `[src]` / `[href]` attribute
// values, `[style]` inline url(), and `<style>` block url() references.
// Skips absolute URLs (http(s)://, //, data:, /-prefixed root-relative),
// hash anchors, and plain relative paths (`assets/x.mp4`) — only `../`
// traversal is flagged. Subsumes the older `../capture/`-specific rule.
// fallow-ignore-next-line complexity
({ tags, styles, rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const offenders: string[] = [];
const collect = (value: string | null) => {
if (!value) return;
const trimmed = value.trim();
if (!trimmed.startsWith("../") && trimmed !== "..") return;
offenders.push(trimmed);
};
for (const tag of tags) {
collect(readAttr(tag.raw, "src"));
collect(readAttr(tag.raw, "href"));
// Use readJsonAttr for `style` — inline url('...') values contain the
// opposite quote, which readAttr's [^"']+ class would truncate.
const styleAttr = readJsonAttr(tag.raw, "style");
if (styleAttr) {
for (const url of extractCssUrlReferences(styleAttr)) collect(url);
}
}
for (const style of styles) {
for (const url of extractCssUrlReferences(style.content)) collect(url);
}
if (offenders.length === 0) return [];
// Group counts by leading path token (e.g. ../capture/, ../assets/, ../../assets/)
// so the message names the offending prefixes instead of a bare count.
const prefixCounts = new Map<string, number>();
for (const path of offenders) {
const prefix = path.match(/^(?:\.\.\/)+[^/]+\//)?.[0] ?? path;
prefixCounts.set(prefix, (prefixCounts.get(prefix) ?? 0) + 1);
}
const prefixSummary = Array.from(prefixCounts.entries())
.sort(([, a], [, b]) => b - a)
.map(([prefix, count]) => (count > 1 ? `${prefix} (${count})` : prefix))
.join(", ");
return [
{
code: "invalid_parent_traversal_in_asset_path",
severity: "error",
message:
`Found ${offenders.length} asset path(s) traversing above the project root with "../" ` +
`(${prefixSummary}). Renders rewrite this against each sub-composition's source path, but Studio preview and other live consumers resolve against the project root and 404.`,
fixHint:
'Use plain root-relative paths (e.g. "assets/...", "capture/...", "fonts/...") — compositions are served with the project root as their base URL, so paths must be root-relative, not relative to the compositions/ directory.',
},
];
},
// composition_file_too_large
({ rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const lineCount = countStructuralLines(rawSource);
if (lineCount <= MAX_COMPOSITION_LINES) return [];
const splitTarget = options.isSubComposition
? "Split this sub-composition further into smaller .html files"
: "Split coherent scenes or layers into separate .html files under compositions/";
return [
{
code: "composition_file_too_large",
severity: "warning",
message: `This HTML composition file has ${lineCount} lines. Smaller sub-compositions are easier to read, iterate on, and diff.`,
fixHint: `${splitTarget}, then mount them from the parent with data-composition-src so each file stays small enough to inspect, revise, and validate independently.`,
},
];
},
// timeline_track_too_dense
// fallow-ignore-next-line complexity
({ tags, options }) => {
const trackCounts = new Map<string, number>();
for (const tag of tags) {
if (TRACK_DENSITY_EXEMPT_TAGS.has(tag.name)) continue;
if (isCompositionRootOrMount(tag.raw)) continue;
if (!readAttr(tag.raw, "data-start")) continue;
const track = readAttr(tag.raw, "data-track-index");
if (!track) continue;
trackCounts.set(track, (trackCounts.get(track) ?? 0) + 1);
}
const findings: HyperframeLintFinding[] = [];
for (const [track, count] of trackCounts) {
if (count <= MAX_TIMED_ELEMENTS_PER_TRACK) continue;
const splitTarget = options.isSubComposition
? "Move coherent scene groups into smaller .html files"
: "Move coherent scene groups into separate .html files under compositions/";
findings.push({
code: "timeline_track_too_dense",
severity: "warning",
message: `Track ${track} has ${count} timed elements in this HTML file. Smaller sub-compositions keep timelines easier to read, iterate on, and diff.`,
fixHint: `${splitTarget} and mount them from the parent with data-composition-src so the timeline stays easier to inspect, revise, and validate.`,
});
}
return findings;
},
// timed_element_missing_visibility_hidden
// fallow-ignore-next-line complexity
({ 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
// fallow-ignore-next-line complexity
({ 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: "error",
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: "error",
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;
},
// split_data_attribute_selector
({ scripts, styles }) => {
const findings: HyperframeLintFinding[] = [];
const splitDataAttrSelectorPattern =
/\[data-composition-id=(["'])([^"'\]]+)\1\s+(data-[\w:-]+)=(["'])([^"'\]]*)\4\]/g;
const scan = (content: string) => {
splitDataAttrSelectorPattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = splitDataAttrSelectorPattern.exec(content)) !== null) {
const compId = match[2] ?? "";
const attrName = match[3] ?? "";
const attrValue = match[5] ?? "";
findings.push({
code: "split_data_attribute_selector",
severity: "error",
message:
`Selector "${match[0]}" combines two attributes inside one CSS attribute selector. ` +
"Browsers reject it, so GSAP timelines or querySelector calls will fail before registering.",
selector: match[0],
fixHint: `Use separate attribute selectors: [data-composition-id="${compId}"][${attrName}="${attrValue}"].`,
snippet: truncateSnippet(match[0]),
});
}
};
for (const style of styles) scan(style.content);
for (const script of scripts) scan(script.content);
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;
},
// timed_element_missing_clip_class
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
const skipTags = new Set(["audio", "video", "script", "style", "template"]);
for (const tag of tags) {
if (skipTags.has(tag.name)) continue;
// Skip composition hosts
if (readAttr(tag.raw, "data-composition-id")) continue;
if (readAttr(tag.raw, "data-composition-src")) continue;
const hasStart = readAttr(tag.raw, "data-start") !== null;
const hasDuration = readAttr(tag.raw, "data-duration") !== null;
// data-track-index alone marks a layer container, not a time-bounded clip
if (!hasStart && !hasDuration) continue;
const classAttr = readAttr(tag.raw, "class") || "";
const hasClip = classAttr.split(/\s+/).includes("clip");
if (hasClip) continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "timed_element_missing_clip_class",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has timing attributes but no class="clip". The element will be visible for the entire composition instead of only during its scheduled time range.`,
elementId,
fixHint:
'Add class="clip" to the element. The HyperFrames runtime uses .clip to control visibility based on data-start/data-duration.',
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},
// overlapping_clips_same_track
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
type ClipInfo = { start: number; end: number; elementId?: string; snippet: string };
const trackMap = new Map<string, ClipInfo[]>();
for (const tag of tags) {
const startStr = readAttr(tag.raw, "data-start");
const durationStr = readAttr(tag.raw, "data-duration");
const trackStr = readAttr(tag.raw, "data-track-index");
if (!startStr || !durationStr || !trackStr) continue;
const start = Number(startStr);
const duration = Number(durationStr);
const track = trackStr;
// Skip non-numeric (relative timing references like "intro-comp")
if (Number.isNaN(start) || Number.isNaN(duration)) continue;
const clips = trackMap.get(track) || [];
clips.push({
start,
end: start + duration,
elementId: readAttr(tag.raw, "id") || undefined,
snippet: truncateSnippet(tag.raw) || "",
});
trackMap.set(track, clips);
}
for (const [track, clips] of trackMap) {
clips.sort((a, b) => a.start - b.start);
for (let i = 0; i < clips.length - 1; i++) {
const current = clips[i];
const next = clips[i + 1];
if (!current || !next) continue;
if (current.end > next.start) {
findings.push({
code: "overlapping_clips_same_track",
severity: "error",
message: `Track ${track}: clip ending at ${current.end}s overlaps with clip starting at ${next.start}s. Overlapping clips on the same track cause rendering conflicts.`,
fixHint:
"Adjust data-start or data-duration so clips on the same track do not overlap, or move one clip to a different data-track-index.",
});
}
}
}
return findings;
},
// root_composition_missing_data_start
({ rootTag, options }) => {
const findings: HyperframeLintFinding[] = [];
if (options.isSubComposition) return findings;
if (!rootTag) return findings;
const compId = readAttr(rootTag.raw, "data-composition-id");
if (!compId) return findings;
const hasStart = readAttr(rootTag.raw, "data-start") !== null;
if (!hasStart) {
findings.push({
code: "root_composition_missing_data_start",
severity: "error",
message: `Root composition "${compId}" is missing data-start. The runtime needs data-start="0" on the root element to begin playback.`,
fixHint: 'Add data-start="0" to the root composition element.',
snippet: truncateSnippet(rootTag.raw),
});
}
return findings;
},
// standalone_composition_wrapped_in_template
({ rawSource, options }) => {
const findings: HyperframeLintFinding[] = [];
if (options.isSubComposition) return findings;
const trimmed = rawSource.trimStart().toLowerCase();
if (trimmed.startsWith("<template")) {
findings.push({
code: "standalone_composition_wrapped_in_template",
severity: "error",
message:
"Root index.html is wrapped in a <template> tag. " +
"Only sub-compositions loaded via data-composition-src should use <template> wrappers. " +
"The runtime cannot play a standalone composition inside a template.",
fixHint:
"Remove the <template> wrapper. Use <!DOCTYPE html><html>...<div data-composition-id>...</div>...</html> instead.",
});
}
return findings;
},
// root_composition_missing_html_wrapper
({ rawSource, rootTag, options }) => {
const findings: HyperframeLintFinding[] = [];
if (options.isSubComposition) return findings;
const trimmed = rawSource.trimStart().toLowerCase();
// Compositions inside <template> are caught by standalone_composition_wrapped_in_template
if (trimmed.startsWith("<template")) return findings;
const hasDoctype = trimmed.startsWith("<!doctype") || trimmed.startsWith("<html");
const hasComposition = rawSource.includes("data-composition-id");
if (hasComposition && !hasDoctype) {
findings.push({
code: "root_composition_missing_html_wrapper",
severity: "error",
message:
"Composition starts with a bare element instead of a proper HTML document. " +
"An index.html that contains data-composition-id but no <!DOCTYPE html>, <html>, or <body> " +
"is a fragment — browsers quirks-mode it, the preview server cannot load it, and " +
"the bundler will fail to inject runtime scripts.",
fixHint:
'Wrap the composition in <!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>...</body></html>.',
snippet: rootTag ? truncateSnippet(rootTag.raw) : undefined,
});
}
return findings;
},
// requestanimationframe_in_composition
({ scripts, rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const stripped = stripJsComments(script.content);
if (/requestAnimationFrame\s*\(/.test(stripped)) {
findings.push({
code: "requestanimationframe_in_composition",
severity: "error",
message:
"`requestAnimationFrame` runs on wall-clock time, not the GSAP timeline. It will not sync with frame capture and may cause flickering or missed frames during rendering.",
fixHint:
"Use GSAP tweens or onUpdate callbacks instead of requestAnimationFrame for animation logic.",
snippet: truncateSnippet(script.content),
});
}
}
return findings;
},
// invalid_variable_values_json
// Host elements (`[data-composition-src]`) carry per-instance values via
// `data-variable-values`. The runtime swallows JSON errors silently and
// falls back to declared defaults, which masks typos. This rule surfaces
// the parse failure so authors notice before render time.
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
const raw = readJsonAttr(tag.raw, "data-variable-values");
if (!raw) continue;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
const reason = err instanceof Error ? err.message : "unknown";
findings.push({
code: "invalid_variable_values_json",
severity: "error",
message: `data-variable-values is not valid JSON (${reason}).`,
fixHint:
'Wrap the attribute value in single quotes and the JSON keys/values in double quotes, e.g. data-variable-values=\'{"title":"Hello"}\'.',
elementId: readAttr(tag.raw, "id") || undefined,
snippet: truncateSnippet(tag.raw),
});
continue;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
findings.push({
code: "invalid_variable_values_json",
severity: "error",
message:
'data-variable-values must be a JSON object keyed by variable id (e.g. {"title":"Hello"}).',
fixHint:
"Replace the value with a JSON object whose keys are variable ids declared in the sub-composition's data-composition-variables.",
elementId: readAttr(tag.raw, "id") || undefined,
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
// invalid_composition_variables_declaration
// The runtime parses `data-composition-variables` and silently returns []
// on any structural problem. Surface JSON / shape failures so authors
// catch them at lint time rather than wondering why their `getVariables()`
// defaults aren't applied.
// fallow-ignore-next-line complexity
({ source }) => {
const htmlTag = findHtmlTag(source);
if (!htmlTag) return [];
const raw = readJsonAttr(htmlTag.raw, "data-composition-variables");
if (!raw) return [];
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
const reason = err instanceof Error ? err.message : "unknown";
return [
{
code: "invalid_composition_variables_declaration",
severity: "error",
message: `data-composition-variables is not valid JSON (${reason}).`,
fixHint:
'Provide a JSON array of variable declarations: data-composition-variables=\'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
snippet: truncateSnippet(htmlTag.raw),
},
];
}
if (!Array.isArray(parsed)) {
return [
{
code: "invalid_composition_variables_declaration",
severity: "error",
message: "data-composition-variables must be a JSON array of variable declarations.",
fixHint:
'Wrap declarations in [] and give each an id, type, label, and default: \'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
snippet: truncateSnippet(htmlTag.raw),
},
];
}
const findings: HyperframeLintFinding[] = [];
const knownTypes = new Set<string>(COMPOSITION_VARIABLE_TYPES);
for (let i = 0; i < parsed.length; i += 1) {
const entry = parsed[i];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
findings.push({
code: "invalid_composition_variables_declaration",
severity: "error",
message: `data-composition-variables entry [${i}] must be an object with id, type, label, and default.`,
snippet: truncateSnippet(htmlTag.raw),
});
continue;
}
const e = entry as Record<string, unknown>;
const missing: string[] = [];
if (typeof e.id !== "string") missing.push("id");
if (typeof e.type !== "string" || !knownTypes.has(e.type)) missing.push("type");
if (typeof e.label !== "string") missing.push("label");
if (!("default" in e)) missing.push("default");
if (missing.length > 0) {
findings.push({
code: "invalid_composition_variables_declaration",
severity: "error",
message: `data-composition-variables entry [${i}] is missing or has invalid: ${missing.join(", ")}. Type must be one of string, number, color, boolean, enum, font, image.`,
snippet: truncateSnippet(htmlTag.raw),
});
}
}
return findings;
},
// subcomposition_blanks_before_host
// Warns when a full-bleed sub-composition slot ends before the host composition
// does, leaving the slot blank for the remainder (issue #1540). Scoped narrowly to
// the high-signal shape — a sole/dominant external mount starting at ~0 — so it
// stays silent on intentional short clips (an intro followed by other clips that
// carry the timeline forward).
// fallow-ignore-next-line complexity
({ tags, rootTag }) => {
if (!rootTag) return [];
const rootDuration = Number(readAttr(rootTag.raw, "data-duration"));
if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
// Two independent knobs that happen to share a 0.5s magnitude. Tuned for
// real hosts (tens to hundreds of seconds); on a very short host (~6s) the
// EPSILON slack would let a ~10% blank tail pass unflagged — acceptable
// because the silent-blank trap this rule targets only matters at scale.
const EPSILON = 0.5; // seconds; tolerance for "ends/covers near the host end"
const START_TOLERANCE = 0.5; // seconds; "starts at the composition start"
const round3 = (n: number) => Math.round(n * 1000) / 1000;
// Timed children of the root. An element with data-start but no usable
// data-duration is treated as covering the tail (end = Infinity), so an
// unknown-length sibling suppresses the warning rather than triggering it.
const timed = tags
.filter((tag) => tag.index !== rootTag.index && readAttr(tag.raw, "data-start") !== null)
.map((tag) => {
const start = Number(readAttr(tag.raw, "data-start")) || 0;
const dur = Number(readAttr(tag.raw, "data-duration"));
const end = Number.isFinite(dur) && dur > 0 ? start + dur : Infinity;
return { tag, start, end };
});
// `tags` is a flat list (no nesting depth), so a timed element nested
// *inside* a candidate slot is treated as a tail-covering sibling rather
// than a descendant. Acceptable: external src mounts are empty by
// convention (content is loaded from the linked file), so the only
// false-negative path is rare and matches the flat-tag scope of the
// sibling rules in this file.
const tailCovered = (exceptIndex: number) =>
timed.some((t) => t.tag.index !== exceptIndex && t.end >= rootDuration - EPSILON);
const findings: HyperframeLintFinding[] = [];
for (const t of timed) {
if (readAttr(t.tag.raw, "data-composition-src") === null) continue; // external slot only
if (t.start > START_TOLERANCE) continue; // must start at the composition start
if (!Number.isFinite(t.end)) continue; // known, finite slot length
if (t.end >= rootDuration - EPSILON) continue; // already fills the host window
if (tailCovered(t.tag.index)) continue; // another clip covers the tail — not full-bleed
const elementId = readAttr(t.tag.raw, "id") || undefined;
const gap = round3(rootDuration - t.end);
findings.push({
code: "subcomposition_blanks_before_host",
severity: "warning",
message: `<${t.tag.name}${elementId ? ` id="${elementId}"` : ""}> sub-composition ends at ${round3(t.end)}s but the composition runs to ${round3(rootDuration)}s — its slot will be blank for ~${gap}s.`,
elementId,
fixHint: `data-duration is the slot's visible window. Set this sub-composition's data-duration to ${round3(rootDuration - t.start)} to fill the host window, or add another clip to cover the remaining ~${gap}s.`,
snippet: truncateSnippet(t.tag.raw),
});
}
return findings;
},
// subcomposition_root_styled_by_class
// A sub-composition's <style> is scoped at render time to
// `[data-composition-id="<id>"] <selector>` so scenes inlined into one document
// can't leak styles into each other. A rule whose LEFTMOST selector is the ROOT
// element's own class (e.g. `.frame { ... }` on the same element that carries
// data-composition-id) therefore becomes a DESCENDANT selector that can never
// match the root — the whole scene renders unstyled (tiny text top-left, images
// at natural size). lint/validate/inspect evaluate the file in isolation (no
// scoping) and Studio previews each scene in its own iframe (no scoping), so the
// break is invisible until the composited MP4 render. Style the root via `#root`
// (the scoper special-cases the root id) and descendants via plain selectors,
// like the registry blocks — the runtime already scopes each scene by id, so a
// class namespace on the root is redundant.
({ rootTag, rootCompositionId, styles, options }) => {
if (!options.isSubComposition) return [];
if (isRegistrySourceFile(options.filePath)) return [];
if (!rootTag || !rootCompositionId) return [];
const rootClasses = (readAttr(rootTag.raw, "class") || "").split(/\s+/).filter(Boolean);
if (rootClasses.length === 0) return [];
const offenders = rootClassStyledSelectors(styles, rootClasses);
if (offenders.length === 0) return [];
const example = offenders.slice(0, 3).join(", ");
return [
{
code: "subcomposition_root_styled_by_class",
severity: "error",
message:
`Root element has class="${rootClasses.join(" ")}" and is styled by ${offenders.length} rule(s) keyed off that class (e.g. ${example}). ` +
`At render, every sub-composition rule is scoped to [data-composition-id="${rootCompositionId}"] <selector>, so a selector whose leftmost part is the ROOT's own class becomes a descendant selector that cannot match the root — the scene renders unstyled (tiny text top-left, full-size images). ` +
`lint/validate/inspect and Studio's per-frame iframe preview do not scope, so this passes every static check and looks correct in preview.`,
selector: example,
fixHint: `Give the root id="root" and style it with \`#root { ... }\` plus plain descendant selectors (\`.kicker\`, \`#hero\`) — the runtime already scopes each sub-composition by data-composition-id, so a class namespace on the root is redundant and breaks under scoping.`,
snippet: truncateSnippet(rootTag.raw),
},
];
},
];
-730
View File
@@ -1,730 +0,0 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
function compositionWithHead(headContent: string): string {
return `
<html>
<head>
${headContent}
</head>
<body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body>
</html>`;
}
function compositionWithHeadBoundary(boundaryContent: string): string {
return `
<html>
<head>
<style>
body { margin: 0; }
</style>
</head>
${boundaryContent}
<body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body>
</html>`;
}
function compositionWithBodyPrefix(prefixContent: string, rootContent = ""): string {
return `
<html>
<head>
<style>
body { margin: 0; }
</style>
</head>
<body>
${prefixContent}
<div data-composition-id="c1" data-width="1920" data-height="1080">
${rootContent}
</div>
<script>window.__timelines = {};</script>
</body>
</html>`;
}
function compositionWithImplicitBodyPrefix(prefixContent: string): string {
return `
<html>
<head>
<style>
body { margin: 0; }
</style>
</head>
${prefixContent}
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</html>`;
}
function templateCompositionWithHead(headContent: string): string {
return `
<template>
<html>
<head>
${headContent}
</head>
<body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
</body>
</html>
</template>`;
}
describe("core rules", () => {
it("reports error when root is missing data-composition-id", async () => {
const html = `
<html><body>
<div id="root" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "root_missing_composition_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports error when root is missing data-width or data-height", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "root_missing_dimensions");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("accepts body as the composition root", async () => {
const html = `
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay-flash"></div>
<script>window.__timelines = window.__timelines || {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
});
it("reports error when timeline registry is missing", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
const tl = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_timeline_registry");
expect(finding).toBeDefined();
});
it("does not flag missing_timeline_registry on a sub-composition (inherits from host)", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
const tl = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "missing_timeline_registry");
expect(finding).toBeUndefined();
});
it("reports error for composition host missing data-composition-id", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="host1" data-composition-src="child.html"></div>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "host_missing_composition_id");
expect(finding).toBeDefined();
});
it("reports error when timeline registry is assigned without initializing", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("without initializing");
});
it("reports error when dot timeline registry is assigned without initializing", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines.c1 = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not flag timeline assignment when init guard is present", async () => {
const validComposition = `
<html>
<body>
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script src="https://cdn.gsap.com/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["comp-1"] = tl;
</script>
</body>
</html>`;
const result = await lintHyperframeHtml(validComposition);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeUndefined();
});
it("reports error when CSS text is left outside a style block in the document head", async () => {
const html = compositionWithHead(`
<style>
body { margin: 0; }
</style>
</style>
/* Decorative Elements */
.particle {
position: absolute;
width: 4px;
height: 4px;
background: #fff;
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("<head>");
expect(finding?.snippet).toContain(".particle");
});
it("reports error when CSS variables leak between head and body", async () => {
const html = compositionWithHeadBoundary(`
--bg-color: #F5F1E8;
--text-color: #212121;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.message).toContain("<head>");
expect(finding?.snippet).toContain("body");
});
it("reports error when stray close tags leak between head and body", async () => {
const html = compositionWithHeadBoundary(`
</style>
</script>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("</style>");
});
it("reports error when markdown code fences leak between head and body", async () => {
const html = compositionWithHeadBoundary(`
\`\`\`css
.particle {
color: white;
}
\`\`\`
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("```css");
});
it("reports error when CSS at-rules leak between head and body", async () => {
const html = compositionWithHeadBoundary(`
@media (min-width: 800px) {
.particle {
transform: scale(1.2);
}
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("@media");
});
it("does not report leaked text for valid script and style blocks around the head boundary", async () => {
const html = compositionWithHeadBoundary(`
<script>
window.__headReady = true;
</script>
<template>
<style>
.template-only { color: red; }
</style>
</template>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("reports error when CSS text leaks before the composition root", async () => {
const html = compositionWithBodyPrefix(`
.orphan {
position: absolute;
inset: 0;
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain(".orphan");
});
it("reports error when CSS text leaks before the composition root without an explicit body", async () => {
const html = compositionWithImplicitBodyPrefix(`
.implicit-body-orphan {
position: absolute;
inset: 0;
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain(".implicit-body-orphan");
});
it("does not report leaked text for valid script and style blocks before the composition root", async () => {
const html = compositionWithBodyPrefix(`
<style>
.pre-root-helper { color: red; }
</style>
<script>
window.__preRootReady = true;
</script>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("does not report CSS-looking educational text inside the composition root", async () => {
const html = compositionWithBodyPrefix(
"",
`
<pre>
body {
margin: 0;
}
</pre>
`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("reports error when a stray style close tag is left in the document head", async () => {
const html = compositionWithHead(`
<style>
body { margin: 0; }
</style>
</style>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("</style>");
});
it("reports error when a stray script close tag is left in the document head", async () => {
const html = compositionWithHead(`
<script>
window.__headReady = true;
</script>
</script>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("</script>");
});
it("does not report leaked head text for valid closing tags with trailing whitespace", async () => {
const html = compositionWithHead(`
<style>
body { margin: 0; }
</style data-parser-error-close>
<script>
window.__headReady = true;
</script
data-parser-error-close>
<title>Particle Field</title >
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("reports error when markdown code fences leak into the document head", async () => {
const withLanguage = compositionWithHead(`
\`\`\`css
.particle {
position: absolute;
}
\`\`\`
`);
const withoutLanguage = compositionWithHead(`
\`\`\`
.particle {
position: absolute;
}
\`\`\`
`);
const withTsxLanguage = compositionWithHead(`
\`\`\`tsx
export function Particle() {
return <div className="particle" />;
}
\`\`\`
`);
const withLanguageResult = await lintHyperframeHtml(withLanguage);
const withoutLanguageResult = await lintHyperframeHtml(withoutLanguage);
const withTsxLanguageResult = await lintHyperframeHtml(withTsxLanguage);
const languageFinding = withLanguageResult.findings.find((f) => f.code === "head_leaked_text");
const unlabeledFinding = withoutLanguageResult.findings.find(
(f) => f.code === "head_leaked_text",
);
const tsxLanguageFinding = withTsxLanguageResult.findings.find(
(f) => f.code === "head_leaked_text",
);
expect(languageFinding).toBeDefined();
expect(languageFinding?.snippet).toContain("```css");
expect(unlabeledFinding).toBeDefined();
expect(unlabeledFinding?.snippet).toContain("```");
expect(tsxLanguageFinding).toBeDefined();
expect(tsxLanguageFinding?.snippet).toContain("```tsx");
});
it("reports error when CSS at-rules leak into the document head", async () => {
const html = compositionWithHead(`
@media (min-width: 800px) {
.particle {
transform: scale(1.2);
}
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("@media");
});
it("reports leaked CSS when a style block is unclosed in the document head", async () => {
const html = compositionWithHead(`
<style>
.particle {
color: white;
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain(".particle");
});
it("does not report leaked head text for commented CSS", async () => {
const html = compositionWithHead(`
<!-- .particle { color: red; } -->
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("does not report leaked head text for valid noscript content", async () => {
const html = compositionWithHead(`
<noscript>
.no-js { display: block; }
</noscript>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("does not report orphan CSS for valid head metadata and style blocks", async () => {
const html = compositionWithHead(`
<title>Particle Field</title>
<meta name="description" content="Particle field">
<link rel="preconnect" href="https://fonts.gstatic.com">
<base href="https://example.com/">
<style>
.particle {
position: absolute;
width: 4px;
height: 4px;
}
</style>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("reports leaked head text inside template-wrapped sub-compositions", async () => {
const html = templateCompositionWithHead(`
</style>
.particle { color: white; }
`);
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain(".particle");
});
describe("timeline_id_mismatch", () => {
it("accepts dot timeline registration", async () => {
const html = `
<html><body>
<div data-composition-id="launch" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines.launch = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
expect(finding).toBeUndefined();
});
it("reports mismatched dot timeline registration", async () => {
const html = `
<html><body>
<div data-composition-id="launch" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines.intro = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
expect(finding).toBeDefined();
expect(finding?.message).toContain('Timeline registered as "intro"');
});
it("accepts bracket timeline registration for hyphenated ids", async () => {
const html = `
<html><body>
<div data-composition-id="product-launch" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["product-launch"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
expect(finding).toBeUndefined();
});
});
it("warns when a timeline-visible element has no stable id for Studio editing", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<section class="clip hero-card" data-start="0" data-duration="3"></section>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "studio_missing_editable_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.message).toContain('<section class="hero-card" data-start="0">');
expect(finding?.fixHint).toContain("stable, human-readable id");
});
it("does not warn about the composition root or timeline elements with ids", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0">
<section id="hero-card" class="clip hero-card" data-start="0" data-duration="3"></section>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "studio_missing_editable_id");
expect(finding).toBeUndefined();
});
describe("non_deterministic_code", () => {
it("detects Math.random() in script content", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const x = Math.random();
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Math.random");
});
it("detects Date.now() in script content", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const ts = Date.now();
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Date.now");
});
it("does not flag non-deterministic calls inside single-line comments", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
// const x = Math.random();
// Date.now() is not used here
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
expect(finding).toBeUndefined();
});
});
describe("composition_self_attribute_selector", () => {
it("warns when inline CSS targets the root composition id", async () => {
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 = await 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", async () => {
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 = await 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", async () => {
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 = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector");
expect(finding).toBeUndefined();
});
});
});
-475
View File
@@ -1,475 +0,0 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import postcss from "postcss";
import {
readAttr,
truncateSnippet,
stripJsComments,
extractCompositionIdsFromCss,
extractTimelineRegistryKeys,
getInlineScriptSyntaxError,
TIMELINE_REGISTRY_INIT_PATTERN,
TIMELINE_REGISTRY_ASSIGN_PATTERN,
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);
}
function isStudioTimelineElement(tag: { raw: string; name: string }): boolean {
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) {
return false;
}
return Boolean(
readAttr(tag.raw, "data-start") ||
readAttr(tag.raw, "data-track-index") ||
readAttr(tag.raw, "data-track") ||
readAttr(tag.raw, "data-composition-src") ||
readAttr(tag.raw, "data-composition-file"),
);
}
function describeStudioElement(tag: { raw: string; name: string }): string {
const parts = [`<${tag.name}`];
const className = readAttr(tag.raw, "class");
const compositionId = readAttr(tag.raw, "data-composition-id");
const dataStart = readAttr(tag.raw, "data-start");
const dataTrack = readAttr(tag.raw, "data-track-index") ?? readAttr(tag.raw, "data-track");
if (className) {
const primaryClass = className
.split(/\s+/)
.map((value) => value.trim())
.find((value) => value && value !== "clip");
if (primaryClass) parts.push(` class="${primaryClass}"`);
}
if (compositionId) parts.push(` data-composition-id="${compositionId}"`);
if (dataStart) parts.push(` data-start="${dataStart}"`);
if (dataTrack) parts.push(` data-track-index="${dataTrack}"`);
parts.push(">");
return parts.join("");
}
const HEAD_BLOCKS_TO_IGNORE_PATTERN =
/<(?:style|script|template|title|noscript)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title|noscript)(?:\s[^>]*)?>/gi;
const HTML_TAG_PATTERN = /<[^>]+>/g;
const HEAD_CONTENT_PATTERN = /<head\b[^>]*>([\s\S]*?)(?:<\/head>|<body\b|$)/gi;
const AFTER_HEAD_BEFORE_BODY_PATTERN = /<\/head(?:\s[^>]*)?>([\s\S]*?)(?=<body\b|$)/gi;
const STRAY_HEAD_CLOSE_PATTERN = /<\/(?:style|script)(?:\s[^>]*)?>/i;
const MARKDOWN_CODE_FENCE_PATTERN = /```[^\r\n`]*(?:\r?\n|$)[\s\S]*?```/i;
const ORPHAN_CSS_AT_RULE_PATTERN =
/(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i;
const ORPHAN_CSS_RULE_PATTERN =
/(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i;
function findCodeFenceLeak(headWithoutValidBlocks: string): string | null {
return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
}
function findOrphanCssLeak(headContent: string): string | null {
const residualText = headContent
.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ")
.replace(HTML_TAG_PATTERN, " ");
return (
ORPHAN_CSS_AT_RULE_PATTERN.exec(residualText)?.[0] ??
ORPHAN_CSS_RULE_PATTERN.exec(residualText)?.[0] ??
null
);
}
function findStrayCloseLeak(headWithoutValidBlocks: string): string | null {
return STRAY_HEAD_CLOSE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
}
function findLeakedTextInHeadContent(headContent: string): string | null {
const withoutValidBlocks = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ");
return (
findCodeFenceLeak(withoutValidBlocks) ??
findOrphanCssLeak(headContent) ??
findStrayCloseLeak(withoutValidBlocks)
);
}
function findLeakedTextInHead(rawSource: string): string | null {
const headMatches = [...rawSource.matchAll(HEAD_CONTENT_PATTERN)];
for (const match of headMatches) {
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
if (leakedText) return leakedText;
}
return null;
}
function findLeakedTextBetweenHeadAndBody(rawSource: string): string | null {
const boundaryMatches = [...rawSource.matchAll(AFTER_HEAD_BEFORE_BODY_PATTERN)];
for (const match of boundaryMatches) {
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
if (leakedText) return leakedText;
}
return null;
}
function findLeakedTextBeforeCompositionRoot(
source: string,
rootTag: LintContext["rootTag"],
): string | null {
if (!rootTag || rootTag.name === "body") return null;
const bodyOpenMatch = /<body\b[^>]*>/i.exec(source);
const prefixStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
const prefixEnd = rootTag.index;
if (prefixEnd <= prefixStart) return null;
return findLeakedTextInHeadContent(source.slice(prefixStart, prefixEnd));
}
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;
},
// head_leaked_text
({ source, rootTag }) => {
const snippet =
findLeakedTextInHead(source) ??
findLeakedTextBetweenHeadAndBody(source) ??
findLeakedTextBeforeCompositionRoot(source, rootTag);
if (!snippet) return [];
return [
{
code: "head_leaked_text",
severity: "error",
message:
"Detected leaked code or CSS text around the document `<head>` or before the composition root. Browsers render this as visible text in the video.",
fixHint:
"Move CSS into a single `<style>...</style>` block and remove stray close tags, markdown fences, or code text from `<head>`, the `</head>`/`<body>` boundary, or the pre-root body prefix.",
snippet: truncateSnippet(snippet),
},
];
},
// missing_timeline_registry + timeline_registry_missing_init
({ source, rawSource, options }) => {
// Sub-compositions inherit window.__timelines from the host composition
if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
return [];
}
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;
let m: RegExpExecArray | null;
while ((m = compIdRe.exec(source)) !== null) {
if (m[1]) htmlCompIds.add(m[1]);
}
for (const key of extractTimelineRegistryKeys(source)) {
timelineRegKeys.add(key);
}
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|application\/hyperframes-slideshow\+json|importmap|module)["']/.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;
},
// 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;
},
// studio_missing_editable_id
({ tags, rootTag }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (rootTag && tag.index === rootTag.index) continue;
if (!isStudioTimelineElement(tag)) continue;
if (readAttr(tag.raw, "id")) continue;
const descriptor = describeStudioElement(tag);
findings.push({
code: "studio_missing_editable_id",
severity: "warning",
message: `${descriptor} has no id, so Studio cannot use a stable edit target for its timeline and canvas controls.`,
selector: readAttr(tag.raw, "data-composition-id")
? `[data-composition-id="${readAttr(tag.raw, "data-composition-id")}"]`
: undefined,
fixHint:
'Add a stable, human-readable id such as id="hero-title" or id="scene-1-card" to every timeline-visible element you want agents or Studio to edit.',
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},
// non_deterministic_code
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
const patterns: Array<{ pattern: RegExp; label: string; hint: string }> = [
{
pattern: /Math\.random\s*\(/,
label: "Math.random()",
hint: "Use a seeded PRNG (e.g. a simple mulberry32) so renders are deterministic across frames.",
},
{
pattern: /Date\.now\s*\(/,
label: "Date.now()",
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
},
{
pattern: /new\s+Date\s*\(/,
label: "new Date()",
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
},
{
pattern: /performance\.now\s*\(/,
label: "performance.now()",
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
},
{
pattern: /crypto\.getRandomValues\s*\(/,
label: "crypto.getRandomValues()",
hint: "Remove time-dependent code. Use a seeded PRNG for deterministic renders.",
},
];
for (const script of scripts) {
const stripped = stripJsComments(script.content);
for (const { pattern, label, hint } of patterns) {
if (pattern.test(stripped)) {
findings.push({
code: "non_deterministic_code",
severity: "error",
message: `Script contains \`${label}\` which produces non-deterministic output. Renders may differ between frames or runs.`,
fixHint: hint,
snippet: truncateSnippet(script.content),
});
}
}
}
return findings;
},
// pointer_events_none
// fallow-ignore-next-line complexity
({ tags, styles }) => {
const findings: HyperframeLintFinding[] = [];
const reported = new Set<string>();
for (const tag of tags) {
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) continue;
const inlineStyle = readAttr(tag.raw, "style") ?? "";
if (!/pointer-events\s*:\s*none/i.test(inlineStyle)) continue;
const id = readAttr(tag.raw, "id");
const key = id ?? tag.raw;
if (reported.has(key)) continue;
reported.add(key);
findings.push({
code: "pointer_events_none",
severity: "info",
message: `<${tag.name}${id ? ` id="${id}"` : ""}> has \`pointer-events: none\` in its inline style. Elements with this property are harder to select in the Studio preview.`,
elementId: id || undefined,
fixHint:
"If this element should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
snippet: truncateSnippet(tag.raw),
});
}
for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
root.walkDecls("pointer-events", (decl) => {
if (decl.value.trim().toLowerCase() !== "none") return;
const rule = decl.parent;
if (!rule || rule.type !== "rule") return;
const selector = (rule as postcss.Rule).selector;
if (reported.has(selector)) return;
reported.add(selector);
findings.push({
code: "pointer_events_none",
severity: "info",
message: `\`${selector}\` sets \`pointer-events: none\`. Elements matching this selector are harder to select in the Studio preview.`,
selector,
fixHint:
"If these elements should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
});
});
}
return findings;
},
];
-285
View File
@@ -1,285 +0,0 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
async function findByCode(html: string, code: string, isSubComposition = true) {
const result = await lintHyperframeHtml(html, { isSubComposition });
return result.findings.filter((f) => f.code === code);
}
describe("font rules", () => {
describe("google_fonts_import", () => {
it("warns on @import url with fonts.googleapis.com without failing lint", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap');</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const findings = result.findings.filter((f) => f.code === "google_fonts_import");
expect(findings).toHaveLength(1);
expect(findings[0]!.severity).toBe("warning");
expect(result.errorCount).toBe(0);
});
it("warns on <link> to fonts.googleapis.com", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
</div>`;
const findings = await findByCode(html, "google_fonts_import");
expect(findings).toHaveLength(1);
expect(findings[0]!.severity).toBe("warning");
});
it("does not flag local @font-face usage", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>@font-face { font-family: 'Inter'; src: url('../capture/assets/fonts/Inter.woff2'); }</style>
</div>`;
const findings = await findByCode(html, "google_fonts_import");
expect(findings).toHaveLength(0);
});
it("does not flag installed registry blocks that bundle Google Fonts", async () => {
const html =
`<!-- hyperframes-registry-item: my-block -->\n` +
`<div data-composition-id="test" data-width="1920" data-height="1080">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
</div>`;
const findings = await findByCode(html, "google_fonts_import");
expect(findings).toHaveLength(0);
});
});
describe("system_font_will_alias", () => {
it("flags SF Mono as aliased to JetBrains Mono", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>code { font-family: 'SF Mono', monospace; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(1);
expect(findings[0]!.severity).toBe("info");
expect(findings[0]!.message).toContain("JetBrains Mono");
});
it("flags Helvetica Neue as aliased to Inter", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Helvetica Neue', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("Inter");
});
it("does not flag canonical font names", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Inter', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(0);
});
it("does not flag Roboto (canonical name)", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Roboto', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(0);
});
it("does not flag unknown fonts (handled by font_family_without_font_face)", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Comic Sans MS', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(0);
});
it("does not flag aliased fonts that have explicit @font-face", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@font-face { font-family: 'Menlo'; src: url('../fonts/menlo.woff2'); }
code { font-family: 'Menlo', monospace; }
</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(0);
});
it("handles case-insensitive font names", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'VERDANA', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("Inter");
});
it("reports multiple aliased fonts in one finding", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
body { font-family: 'Verdana', sans-serif; }
code { font-family: 'Consolas', monospace; }
</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("Inter");
expect(findings[0]!.message).toContain("JetBrains Mono");
});
});
describe("font_family_without_font_face", () => {
it("flags font-family used without @font-face", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'GT Walsheim', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("gt walsheim");
});
it("does not flag when @font-face is declared", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@font-face { font-family: 'GT Walsheim'; src: url('../fonts/gt.woff2'); }
body { font-family: 'GT Walsheim', sans-serif; }
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("does not flag generic font families", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: monospace; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("reports multiple missing families in one finding", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
h1 { font-family: 'Aeonik', sans-serif; }
code { font-family: 'Feature Deck', monospace; }
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("aeonik");
expect(findings[0]!.message).toContain("feature deck");
});
it("does not flag fonts the producer has pre-bundled", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
body { font-family: 'Inter', sans-serif; }
code { font-family: 'JetBrains Mono', monospace; }
h1 { font-family: 'Roboto', sans-serif; }
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("still flags Google-Fonts-only fonts not pre-bundled", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Geist', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("geist");
});
it("does not flag a non-bundled family when a Google Fonts link loads it", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;700&display=swap">
<style>body { font-family: 'Geist', sans-serif; }</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(result.findings.filter((f) => f.code === "google_fonts_import")).toHaveLength(1);
expect(
result.findings.filter((f) => f.code === "font_family_without_font_face"),
).toHaveLength(0);
expect(result.errorCount).toBe(0);
});
it("parses unquoted Google Fonts link href values", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<link rel=stylesheet href=https://fonts.googleapis.com/css2?family=Geist:wght@400;700&display=swap>
<style>body { font-family: 'Geist', sans-serif; }</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(result.findings.filter((f) => f.code === "google_fonts_import")).toHaveLength(1);
expect(
result.findings.filter((f) => f.code === "font_family_without_font_face"),
).toHaveLength(0);
expect(result.errorCount).toBe(0);
});
it("parses multiple Google Fonts family parameters and URL-encoded spaces", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@import url("https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&family=DM+Sans:ital,wght@0,400;1,700&display=swap");
h1 { font-family: 'Libre Baskerville', serif; }
body { font-family: 'DM Sans', sans-serif; }
</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(result.findings.filter((f) => f.code === "google_fonts_import")).toHaveLength(1);
expect(
result.findings.filter((f) => f.code === "font_family_without_font_face"),
).toHaveLength(0);
expect(result.errorCount).toBe(0);
});
it("still flags non-bundled families not covered by the Google Fonts URL", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
<style>body { font-family: 'Geist', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("geist");
});
it("is case-insensitive when matching @font-face to font-family", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@font-face { font-family: 'Inter'; src: url('../fonts/inter.woff2'); }
body { font-family: 'inter', sans-serif; }
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("ignores font-family inside @font-face blocks", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@font-face { font-family: 'CustomFont'; src: url('../fonts/custom.woff2'); }
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("does not flag installed registry blocks that declare fonts via Google Fonts", async () => {
const html =
`<!-- hyperframes-registry-item: my-block -->\n` +
`<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Poppins', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("matches @font-face even when a CSS comment inside the block contains a brace (#1534)", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@font-face { /* weight 400 } regular */ font-family: 'Noto Sans SC'; src: url('../fonts/noto-400.woff2'); }
.title { font-family: 'Noto Sans SC'; }
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
});
});
-222
View File
@@ -1,222 +0,0 @@
import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "../../fonts/aliases";
import type { LintContext, HyperframeLintFinding } from "../context";
import { isRegistrySourceFile, isRegistryInstalledFile } from "./composition";
const GENERIC_FAMILIES = new Set([
"serif",
"sans-serif",
"monospace",
"cursive",
"fantasy",
"system-ui",
"ui-serif",
"ui-sans-serif",
"ui-monospace",
"ui-rounded",
"math",
"emoji",
"fangsong",
"inherit",
"initial",
"unset",
"revert",
]);
// A CSS comment can contain a `}` (e.g. `@font-face { /* 400 } regular */
// font-family: 'X'; ... }`), which truncates the naive `@font-face\s*\{[^}]*\}`
// block match at the comment's brace — so the rule never sees the real
// `font-family` and reports a false-positive font_family_without_font_face.
// Large/"framework" stylesheets hit this far more often than minimal ones,
// which is why a simple <style> passes while a complex one fails. Strip
// comments before scanning so a brace inside one cannot split a block. See #1534.
function stripCssComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
}
function extractFontFaceFamilies(styles: Array<{ content: string }>): Set<string> {
const families = new Set<string>();
const fontFaceRe = /@font-face\s*\{[^}]*\}/gi;
const familyRe = /font-family\s*:\s*(['"]?)([^;'"]+)\1/i;
for (const style of styles) {
const content = stripCssComments(style.content);
let match: RegExpExecArray | null;
while ((match = fontFaceRe.exec(content)) !== null) {
const familyMatch = match[0].match(familyRe);
if (familyMatch?.[2]) {
families.add(familyMatch[2].trim().toLowerCase());
}
}
}
return families;
}
function extractUsedFontFamilies(styles: Array<{ content: string }>): string[] {
const used: string[] = [];
const seen = new Set<string>();
const propRe = /font-family\s*:\s*([^;}{]+)/gi;
for (const style of styles) {
const withoutFontFace = stripCssComments(style.content).replace(/@font-face\s*\{[^}]*\}/gi, "");
let match: RegExpExecArray | null;
while ((match = propRe.exec(withoutFontFace)) !== null) {
const stack = match[1]!;
for (const part of stack.split(",")) {
const name = part
.trim()
.replace(/^['"]|['"]$/g, "")
.trim()
.toLowerCase();
if (name && !GENERIC_FAMILIES.has(name) && !seen.has(name)) {
seen.add(name);
used.push(name);
}
}
}
}
return used;
}
function collectAliasedFonts(used: string[], declared: Set<string>): string[] {
const aliased: string[] = [];
for (const name of used) {
if (declared.has(name)) continue;
const displayName = resolveAliasDisplayName(name);
if (!displayName) continue;
if (displayName.toLowerCase() === name) continue;
aliased.push(`'${name}' → ${displayName}`);
}
return aliased;
}
function normalizeFontFamily(name: string): string | null {
const decoded = name.replace(/\+/g, " ").trim();
if (!decoded) return null;
try {
return decodeURIComponent(decoded).trim().toLowerCase() || null;
} catch {
return decoded.toLowerCase();
}
}
function extractGoogleFontFamiliesFromUrl(rawUrl: string): string[] {
const url = rawUrl.replace(/&amp;/gi, "&");
let parsed: URL;
try {
parsed = new URL(url, "https://fonts.googleapis.com");
} catch {
return [];
}
if (parsed.hostname.toLowerCase() !== "fonts.googleapis.com") return [];
const families: string[] = [];
for (const value of parsed.searchParams.getAll("family")) {
for (const familySpec of value.split("|")) {
const family = normalizeFontFamily(familySpec.split(":")[0] || "");
if (family) families.push(family);
}
}
return families;
}
function collectGoogleFontFamilies(
source: string,
styles: Array<{ content: string }>,
): Set<string> {
const families = new Set<string>();
const addUrl = (url: string) => {
for (const family of extractGoogleFontFamiliesFromUrl(url)) families.add(family);
};
const linkHrefRe =
/<link\b[^>]*\bhref\s*=\s*(?:(["'])([^"']*fonts\.googleapis\.com[^"']*)\1|([^\s>]*fonts\.googleapis\.com[^\s>]*))[^>]*>/gi;
for (const match of source.matchAll(linkHrefRe)) {
const href = match[2] || match[3];
if (href) addUrl(href);
}
const importUrlRe =
/@import\s+(?:url\(\s*)?(["']?)([^"')\s]*fonts\.googleapis\.com[^"')\s]*)\1\s*\)?/gi;
for (const style of styles) {
for (const match of style.content.matchAll(importUrlRe)) {
if (match[2]) addUrl(match[2]);
}
}
return families;
}
export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// google_fonts_import
({ styles, source, rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const findings: HyperframeLintFinding[] = [];
const googleFontsInLink = /<link\b[^>]*fonts\.googleapis\.com[^>]*>/i.test(source);
const googleFontsInImport = styles.some((s) =>
/@import\s+url\s*\(\s*['"]?[^)]*fonts\.googleapis\.com/i.test(s.content),
);
if (googleFontsInLink || googleFontsInImport) {
findings.push({
code: "google_fonts_import",
severity: "warning",
message:
"Composition loads fonts from fonts.googleapis.com. The producer resolves Google Fonts " +
"during compile/render, but raw external font requests add latency and can fail before " +
"canonicalization. Prefer mapped family names or local @font-face declarations when possible.",
fixHint:
"For bundled fonts, remove the Google Fonts <link> or @import and keep the font-family " +
"declaration. For custom fonts, use @font-face { font-family: '...'; src: url('...woff2'); }.",
});
}
return findings;
},
// system_font_will_alias — inform when a font will be silently substituted
({ styles, options }) => {
const declared = extractFontFaceFamilies(styles);
const used = extractUsedFontFamilies(styles);
const aliased = collectAliasedFonts(used, declared);
if (aliased.length === 0) return [];
// In distributed / Lambda renders system-font capture is disabled, so
// the alias substitution does NOT happen — elevate to a warning.
const severity = options.distributed ? ("warning" as const) : ("info" as const);
return [
{
code: "system_font_will_alias",
severity,
message:
`Font ${aliased.length === 1 ? "family" : "families"} will be substituted at render time: ${aliased.join(", ")}. ` +
(options.distributed
? "In distributed/Lambda rendering system-font capture is disabled — these fonts will fall back to OS defaults. Embed explicit @font-face declarations instead."
: "The renderer maps these to bundled fonts for cross-platform consistency. " +
"Use the target font name directly for consistent preview and render results."),
},
];
},
// font_family_without_font_face
({ styles, source, rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const findings: HyperframeLintFinding[] = [];
const declared = extractFontFaceFamilies(styles);
const used = extractUsedFontFamilies(styles);
const googleFonts = collectGoogleFontFamilies(source, styles);
const undeclared = used.filter(
(name) => !declared.has(name) && !FONT_ALIAS_KEYS.has(name) && !googleFonts.has(name),
);
if (undeclared.length === 0) return findings;
findings.push({
code: "font_family_without_font_face",
severity: "error",
message:
`Font ${undeclared.length === 1 ? "family" : "families"} used without @font-face declaration: ${undeclared.join(", ")}. ` +
"These are not in the auto-resolved font list, so the renderer cannot supply them automatically. " +
"Text will fall back to a generic font, producing incorrect typography in the video.",
fixHint:
"Add @font-face { font-family: '...'; src: url('capture/assets/fonts/...woff2'); } " +
"for each font family, pointing to the captured .woff2 files.",
});
return findings;
},
];
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-251
View File
@@ -1,251 +0,0 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("media rules", () => {
it("reports error for duplicate media ids", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="a.mp4" data-start="0" data-duration="5"></video>
<video id="v1" src="b.mp4" data-start="0" data-duration="3"></video>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "duplicate_media_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
});
it("reports error for audio with data-start but no id", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio data-start="0" data-duration="10" src="narration.wav"></audio>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("SILENT");
});
it("reports error for video with data-start but no id", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video data-start="0" data-duration="10" src="clip.mp4" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("FROZEN");
});
it("does not flag media elements that have id", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio id="a1" data-start="0" data-duration="10" src="narration.wav"></audio>
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_id");
expect(finding).toBeUndefined();
});
it("reports warning for media with preload=none", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline preload="none"></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_preload_none");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("reports error for media with id but no src", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio id="a1" data-start="0" data-duration="10"></audio>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_src");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports error for media with src but no data-start", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" src="clip.mp4" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_data_start");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("allows audible video clips to omit muted when data-has-audio is true", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" data-has-audio="true" src="clip.mp4" playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "video_missing_muted")).toBeUndefined();
expect(
result.findings.find((f) => f.code === "video_muted_with_declared_audio"),
).toBeUndefined();
});
it("reports error for videos that declare audio while muted", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" data-has-audio="true" src="clip.mp4" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "video_muted_with_declared_audio");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("does NOT flag <video> as nested in a void element with data-start (regression)", async () => {
// Regression: void elements like <img> have no closing tag, so the previous
// implementation kept them on the parent stack indefinitely and flagged any
// later <video> with data-start as "nested" inside them.
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<img id="hdr-img" src="hdr.png" data-start="0" data-duration="5" data-track-index="0" />
<video id="hdr-vid" src="clip.mp4" data-start="5" data-duration="5" data-track-index="1" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "video_nested_in_timed_element");
expect(finding).toBeUndefined();
});
it("reports imperative play() control on managed media ids", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" src="clip.mp4" muted playsinline></video>
</div>
<script>
const video = document.getElementById("demo-video");
video.play();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("reports imperative currentTime writes on query-selected managed media", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" src="clip.mp4" muted playsinline></video>
</div>
<script>
const demo = document.querySelector("#demo-video");
demo.currentTime = 1.5;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports imperative muted/play control on class-selected media without ids", async () => {
const html = `
<template id="scene-template">
<div data-composition-id="scene" data-width="1920" data-height="1080">
<video class="demo-video" src="clip.mp4" muted playsinline></video>
<script>
const vid = document.querySelector('[data-composition-id="scene"] .demo-video');
if (vid) { vid.muted = true; vid.play(); }
</script>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { filePath: "compositions/scene.html" });
const imperativeFindings = result.findings.filter((f) => f.code === "imperative_media_control");
expect(imperativeFindings.length).toBe(2);
expect(imperativeFindings.some((f) => f.snippet === "vid.muted =")).toBe(true);
expect(imperativeFindings.some((f) => f.snippet === "vid.play(")).toBe(true);
});
it("does not flag play() on non-media elements", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="panel"></div>
</div>
<script>
const panel = document.getElementById("panel");
panel.play?.();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeUndefined();
});
it("flags <video> inside a sub-composition (media must be a host-root child)", async () => {
const html = `<template id="scene-template">
<div id="root" data-composition-id="scene" data-width="1920" data-height="1080">
<video id="v1" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "media_in_subcomposition");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
expect(finding?.message).toContain("sub-composition");
});
it("does not flag media in a host-root (non-sub) composition", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_in_subcomposition");
expect(finding).toBeUndefined();
});
});
-526
View File
@@ -1,526 +0,0 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import { readAttr, truncateSnippet, isMediaTag } from "../utils";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function hasAttrName(tagSource: string, attr: string): boolean {
const escaped = escapeRegExp(attr);
const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, "");
return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
}
function classNamesFromAttr(classAttr: string | null): string[] {
if (!classAttr) return [];
return classAttr.split(/\s+/).filter(Boolean);
}
type MediaSelectorIndex = {
ids: Set<string>;
classes: Set<string>;
hasVideo: boolean;
hasAudio: boolean;
};
function selectorTargetsManagedMedia(selector: string, mediaIndex: MediaSelectorIndex): boolean {
const normalized = selector.trim();
if (!normalized) return false;
if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized)) return true;
if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized)) return true;
for (const mediaId of mediaIndex.ids) {
const escapedId = escapeRegExp(mediaId);
if (
new RegExp(`#${escapedId}(?![\\w-])`).test(normalized) ||
normalized.includes(`[id="${mediaId}"]`) ||
normalized.includes(`[id='${mediaId}']`)
) {
return true;
}
}
for (const className of mediaIndex.classes) {
if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized)) {
return true;
}
}
return false;
}
function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
const mediaTags = ctx.tags.filter((tag) => tag.name === "video" || tag.name === "audio");
const mediaIndex: MediaSelectorIndex = {
ids: new Set(
mediaTags.map((tag) => readAttr(tag.raw, "id")).filter((id): id is string => Boolean(id)),
),
classes: new Set(mediaTags.flatMap((tag) => classNamesFromAttr(readAttr(tag.raw, "class")))),
hasVideo: mediaTags.some((tag) => tag.name === "video"),
hasAudio: mediaTags.some((tag) => tag.name === "audio"),
};
if (mediaTags.length === 0 || ctx.scripts.length === 0) return findings;
for (const script of ctx.scripts) {
const mediaVars = new Map<string, string | undefined>();
const assignmentPatterns = [
{
pattern:
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)/g,
variableIndex: 1,
targetIndex: 2,
},
{
pattern:
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\2\s*\)/g,
variableIndex: 1,
targetIndex: 3,
},
];
for (const { pattern, variableIndex, targetIndex } of assignmentPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(script.content)) !== null) {
const variableName = match[variableIndex];
const target = match[targetIndex];
if (!variableName || !target) continue;
if (mediaIndex.ids.has(target) || selectorTargetsManagedMedia(target, mediaIndex)) {
mediaVars.set(variableName, mediaIndex.ids.has(target) ? target : undefined);
}
}
}
const directIdPatterns = [
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
kind: "play()",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
kind: "pause()",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g,
kind: "currentTime",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.muted\s*=/g,
kind: "muted assignment",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.play\s*\(/g,
kind: "play()",
targetIndex: 2,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.pause\s*\(/g,
kind: "pause()",
targetIndex: 2,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.currentTime\s*=/g,
kind: "currentTime",
targetIndex: 2,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.muted\s*=/g,
kind: "muted assignment",
targetIndex: 2,
},
];
for (const { pattern, kind, targetIndex } of directIdPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(script.content)) !== null) {
const target = match[targetIndex];
if (!target) continue;
const elementId = mediaIndex.ids.has(target)
? target
: selectorTargetsManagedMedia(target, mediaIndex)
? undefined
: null;
if (elementId === null) continue;
findings.push({
code: "imperative_media_control",
severity: "error",
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
elementId: elementId || undefined,
fixHint:
"Remove imperative media play/pause/currentTime/muted control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
snippet: truncateSnippet(match[0]),
});
}
}
for (const [variableName, elementId] of mediaVars) {
const escapedVar = escapeRegExp(variableName);
const variablePatterns = [
{ pattern: new RegExp(`\\b${escapedVar}\\.play\\s*\\(`, "g"), kind: "play()" },
{ pattern: new RegExp(`\\b${escapedVar}\\.pause\\s*\\(`, "g"), kind: "pause()" },
{ pattern: new RegExp(`\\b${escapedVar}\\.currentTime\\s*=`, "g"), kind: "currentTime" },
{
pattern: new RegExp(`\\b${escapedVar}\\.muted\\s*=`, "g"),
kind: "muted assignment",
},
];
for (const { pattern, kind } of variablePatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(script.content)) !== null) {
findings.push({
code: "imperative_media_control",
severity: "error",
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
elementId,
fixHint:
"Remove imperative media play/pause/currentTime/muted control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
snippet: truncateSnippet(match[0]),
});
}
}
}
}
return findings;
}
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 = hasAttrName(tag.raw, "muted");
const hasDeclaredAudio = readAttr(tag.raw, "data-has-audio") === "true";
if (!hasMuted && !hasDeclaredAudio && 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. Mark audible videos with data-has-audio="true"; otherwise keep video muted and use a separate <audio> element for sound.`,
elementId,
fixHint:
'Add the `muted` attribute for silent video, or add data-has-audio="true" when the video track should contribute audio.',
snippet: truncateSnippet(tag.raw),
});
}
if (hasMuted && hasDeclaredAudio) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "video_muted_with_declared_audio",
severity: "error",
message: `<video${elementId ? ` id="${elementId}"` : ""}> declares data-has-audio="true" but also has muted. Studio preview will silence the video audio.`,
elementId,
fixHint:
'Remove the `muted` attribute if this video should be audible, or remove data-has-audio="true" and use data-volume="0" for silent visual video.',
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
// video_nested_in_timed_element
({ source, tags }) => {
const findings: HyperframeLintFinding[] = [];
// HTML5 void elements cannot contain children, so they can never be a
// parent of a nested <video>. Skipping them avoids false positives where
// the linter looks for `</img>` and never finds it.
const voidElements = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"source",
"track",
"wbr",
]);
const timedTagPositions: Array<{ name: string; start: number; id?: string }> = [];
for (const tag of tags) {
if (tag.name === "video" || tag.name === "audio") continue;
if (voidElements.has(tag.name)) continue;
// Skip the composition root — it uses data-start as a playback anchor, not as a clip timer
if (readAttr(tag.raw, "data-composition-id")) 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;
},
// media_in_subcomposition — <video>/<audio> only render as a DIRECT child of the host
// root (index.html). Inside a sub-composition <template> the runtime never seeks/decodes
// them, so they render BLANK/black in preview and renders — and the other lint/validate
// passes otherwise miss it (only a per-frame snapshot reveals the blank panel).
({ tags, options }) => {
const findings: HyperframeLintFinding[] = [];
if (!options.isSubComposition) return findings;
for (const tag of tags) {
if (tag.name !== "video" && tag.name !== "audio") continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "media_in_subcomposition",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> is inside a sub-composition. The runtime only drives media that is a DIRECT child of the host root (index.html); media inside a sub-comp <template> is never seeked/decoded and renders BLANK/black in preview and renders.`,
elementId,
fixHint:
"Move the media OUT of the sub-composition: place the <video>/<audio> as a direct child of #root in index.html, positioned over the scene, and drive any per-scene motion on the MAIN timeline at global time (a sub-comp timeline cannot reach host elements). See composition-patterns.md archetype B.",
snippet: truncateSnippet(tag.raw),
});
}
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_data_start + 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 (hasSrc && !hasDataStart) {
findings.push({
code: "media_missing_data_start",
severity: "error",
message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has src but no data-start. HyperFrames cannot own playback for untimed media, so preview and render behavior can diverge.`,
elementId: hasId || undefined,
fixHint: `Add data-start="0" (or the intended start time) and data-duration if the clip should stop before the source ends.`,
snippet: truncateSnippet(tag.raw),
});
}
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;
},
// video_audio_double_source — catches audible <video> paired with a separate
// <audio> pointing to the same file, which causes double playback at runtime
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
const videoSources = new Map<string, { id?: string; raw: string }>();
const audioSources = new Map<string, { id?: string; raw: string }>();
for (const tag of tags) {
if (!readAttr(tag.raw, "data-start")) continue;
const src = readAttr(tag.raw, "src");
if (!src) continue;
const elementId = readAttr(tag.raw, "id") || undefined;
if (tag.name === "video") {
const isMuted = hasAttrName(tag.raw, "muted");
if (!isMuted) {
videoSources.set(src, { id: elementId, raw: tag.raw });
}
} else if (tag.name === "audio") {
audioSources.set(src, { id: elementId, raw: tag.raw });
}
}
for (const [src, audioInfo] of audioSources) {
const videoInfo = videoSources.get(src);
if (!videoInfo) continue;
findings.push({
code: "video_audio_double_source",
severity: "error",
message: `<audio${audioInfo.id ? ` id="${audioInfo.id}"` : ""}> and <video${videoInfo.id ? ` id="${videoInfo.id}"` : ""}> both point to the same source. The unmuted video already provides audio — the duplicate <audio> will cause double playback and echo.`,
elementId: audioInfo.id,
fixHint:
"Either mute the video (add `muted` attribute) and keep the separate <audio>, or remove the <audio> element and let the video provide its own audio track.",
snippet: truncateSnippet(audioInfo.raw),
});
}
return findings;
},
// imperative_media_control
findImperativeMediaControlFindings,
];
@@ -1,73 +0,0 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
async function findSlideshow(html: string) {
const result = await lintHyperframeHtml(html, { isSubComposition: true });
return result.findings.filter((f) => f.code.startsWith("slideshow_"));
}
describe("slideshow lint rule", () => {
it("passes a composition with no slideshow island", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div id="a" class="clip" data-start="0" data-duration="5"></div>
</div>`;
expect(await findSlideshow(html)).toEqual([]);
});
it("passes a valid island where sceneId resolves to a data-composition-id scene", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div data-composition-id="a" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"a"}]}</script>
</div>`;
expect(await findSlideshow(html)).toEqual([]);
});
it("flags a sceneId that matches only a .clip[id] (not a data-composition-id)", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div id="a" class="clip" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"a"}]}</script>
</div>`;
const findings = await findSlideshow(html);
expect(findings.length).toBeGreaterThan(0);
expect(findings[0]?.message).toContain("a");
});
it("flags an unresolved sceneId", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div id="a" class="clip" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"ghost"}]}</script>
</div>`;
const findings = await findSlideshow(html);
expect(findings.length).toBeGreaterThan(0);
expect(findings[0]!.message).toContain("ghost");
});
it("flags invalid JSON in the island", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<script type="application/hyperframes-slideshow+json">NOT_JSON</script>
</div>`;
const findings = await findSlideshow(html);
expect(findings.length).toBeGreaterThan(0);
expect(findings[0]!.code).toBe("slideshow_invalid");
});
it("passes when sceneId resolves to a data-composition-id element (no .clip[id])", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div data-composition-id="scene-a" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"scene-a"}]}</script>
</div>`;
expect(await findSlideshow(html)).toEqual([]);
});
it("flags a hotspot targeting an unknown sequence", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div data-composition-id="a" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">${JSON.stringify({
slides: [{ sceneId: "a", hotspots: [{ id: "h1", label: "Go", target: "no-such-seq" }] }],
})}</script>
</div>`;
const findings = await findSlideshow(html);
expect(findings.length).toBeGreaterThan(0);
expect(findings[0]!.message).toContain("no-such-seq");
});
});
-82
View File
@@ -1,82 +0,0 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import type { LintRule } from "../types";
import { readAttr } from "../utils";
import { parseSlideshowManifest, resolveSlideshow } from "../../slideshow/parseSlideshow";
import { isSceneLikeCompositionId } from "../../slideshow/sceneId";
type Scene = { id: string; start: number; duration: number };
function parseTiming(raw: string): { start: number; duration: number } | null {
const startStr = readAttr(raw, "data-start");
if (startStr === null) return null;
const start = Number(startStr);
if (!Number.isFinite(start)) return null;
const durationStr = readAttr(raw, "data-duration");
if (durationStr !== null) {
const duration = Number(durationStr);
if (Number.isFinite(duration)) return { start, duration };
}
const endStr = readAttr(raw, "data-end") ?? readAttr(raw, "data-hf-authored-end");
if (endStr !== null) {
const end = Number(endStr);
if (Number.isFinite(end) && end > start) return { start, duration: end - start };
}
return null;
}
function collectCompositionIdScenes(ctx: LintContext, seen: Set<string>, out: Scene[]): void {
for (const tag of ctx.tags) {
const compositionId = readAttr(tag.raw, "data-composition-id");
if (!compositionId || !isSceneLikeCompositionId(compositionId) || seen.has(compositionId))
continue;
const timing = parseTiming(tag.raw);
if (!timing || timing.duration <= 0) continue;
seen.add(compositionId);
out.push({ id: compositionId, ...timing });
}
}
function extractScenesFromClips(ctx: LintContext): Scene[] {
const seen = new Set<string>();
const scenes: Scene[] = [];
collectCompositionIdScenes(ctx, seen, scenes);
return scenes;
}
export const slideshowRules: LintRule<LintContext>[] = [
(ctx) => {
const findings: HyperframeLintFinding[] = [];
let manifest;
try {
manifest = parseSlideshowManifest(ctx.source);
} catch (e) {
findings.push({
code: "slideshow_invalid",
severity: "error",
message: `Slideshow island contains invalid JSON or structure: ${e instanceof Error ? e.message : String(e)}`,
fixHint:
'Ensure the <script type="application/hyperframes-slideshow+json"> block contains valid JSON matching the SlideshowManifest schema.',
});
return findings;
}
if (!manifest) return findings;
const scenes = extractScenesFromClips(ctx);
const { errors } = resolveSlideshow(manifest, scenes);
for (const error of errors) {
findings.push({
code: "slideshow_unresolved_ref",
severity: "error",
message: `Slideshow manifest error: ${error}`,
fixHint:
"Ensure every sceneId in the slideshow island matches the data-composition-id of a scene element in the composition, or provide explicit startTime/endTime.",
});
}
return findings;
},
];
@@ -1,145 +0,0 @@
import { describe, expect, it } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
function baseHtml(body: string, style = ""): string {
return `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
${body}
</div>
<style>${style}</style>
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`;
}
const textureCss = `
.hf-texture-text {
color: #fff;
-webkit-mask-size: var(--mask-size, cover);
mask-size: var(--mask-size, cover);
}
.hf-texture-lava {
-webkit-mask-image: url("masks/lava.png");
mask-image: url("masks/lava.png");
}
`;
describe("texture rules", () => {
it("does not warn for a valid texture mask text usage", async () => {
const html = baseHtml(
'<div class="shadow"><div class="hf-texture-text hf-texture-lava">TEXT</div></div>',
`${textureCss}.shadow { filter: drop-shadow(1px 2px 1px rgba(0,0,0,.48)); }`,
);
const result = await lintHyperframeHtml(html);
expect(result.findings.filter((finding) => finding.code.startsWith("texture_"))).toEqual([]);
});
it("warns when a material class is used without hf-texture-text", async () => {
const html = baseHtml('<div class="hf-texture-lava">TEXT</div>', textureCss);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_class_missing_base");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.fixHint).toContain("hf-texture-text");
});
it("warns when hf-texture-text has no material class or custom mask image", async () => {
const html = baseHtml('<div class="hf-texture-text">TEXT</div>', textureCss);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_text_missing_mask");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("allows hf-texture-text with an inline custom mask image", async () => {
const html = baseHtml(
'<div class="hf-texture-text" style="-webkit-mask-image:url(custom.png); mask-image:url(custom.png)">TEXT</div>',
textureCss,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_text_missing_mask");
expect(finding).toBeUndefined();
});
it("warns when a texture material class is not defined by local CSS", async () => {
const html = baseHtml('<div class="hf-texture-text hf-texture-marbel">TEXT</div>', textureCss);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_class_unknown");
expect(finding).toBeDefined();
expect(finding?.message).toContain("hf-texture-marbel");
});
it("warns when drop-shadow is applied inline to the textured text element", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava" style="filter: drop-shadow(1px 2px 1px black)">TEXT</div>',
textureCss,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.fixHint).toContain("wrapper");
});
it("warns when drop-shadow is applied by CSS directly to hf-texture-text", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava">TEXT</div>',
`${textureCss}.hf-texture-text { filter: drop-shadow(1px 2px 1px black); }`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".hf-texture-text");
});
it("warns when drop-shadow targets a material class before the mask rule is declared", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava">TEXT</div>',
`.hf-texture-lava { filter: drop-shadow(1px 2px 1px black); }
${textureCss}`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".hf-texture-lava");
});
it("warns when drop-shadow targets another class on the textured text element", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava headline">TEXT</div>',
`${textureCss}.headline { filter: drop-shadow(1px 2px 1px black); }`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".headline");
});
it("does not warn when another-class drop-shadow selector needs an unmatched ancestor", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava headline">TEXT</div>',
`${textureCss}.card .headline { filter: drop-shadow(1px 2px 1px black); }`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeUndefined();
});
});
-226
View File
@@ -1,226 +0,0 @@
import postcss from "postcss";
import type { LintContext, HyperframeLintFinding, OpenTag } from "../context";
import { readAttr, truncateSnippet } from "../utils";
const TEXTURE_BASE_CLASS = "hf-texture-text";
const TEXTURE_CLASS_PREFIX = "hf-texture-";
type DropShadowRule = {
selector: string;
directlyTargetsTexture: boolean;
};
function classNames(tag: OpenTag): string[] {
return (readAttr(tag.raw, "class") ?? "").split(/\s+/).filter(Boolean);
}
function isTextureMaterialClass(className: string): boolean {
return className.startsWith(TEXTURE_CLASS_PREFIX) && className !== TEXTURE_BASE_CLASS;
}
function hasInlineMaskImage(tag: OpenTag): boolean {
const style = readAttr(tag.raw, "style") ?? "";
return /\b(?:-webkit-)?mask-image\s*:/i.test(style);
}
function hasInlineDropShadow(tag: OpenTag): boolean {
const style = readAttr(tag.raw, "style") ?? "";
return /\bfilter\s*:\s*[^;]*\bdrop-shadow\s*\(/i.test(style);
}
function classNamesInSelector(selector: string): string[] {
const classes = new Set<string>();
const pattern = /\.([A-Za-z_][\w-]*)/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(selector)) !== null) {
const className = match[1];
if (!className) continue;
classes.add(className);
}
return [...classes];
}
function textureClassesInSelector(selector: string): string[] {
return classNamesInSelector(selector).filter(isTextureMaterialClass);
}
function simpleSelectorMatchesTag(selector: string, tag: OpenTag, tagClasses: string[]): boolean {
const trimmed = selector.trim();
const simpleSelectorPattern = /^(?:[A-Za-z][\w-]*)?(?:\.[A-Za-z_][\w-]*)+$/;
if (!simpleSelectorPattern.test(trimmed)) return false;
const typeMatch = /^([A-Za-z][\w-]*)/.exec(trimmed);
if (typeMatch && typeMatch[1]!.toLowerCase() !== tag.name) return false;
const selectorClasses = classNamesInSelector(trimmed);
return (
selectorClasses.length > 0 &&
selectorClasses.every((className) => tagClasses.includes(className))
);
}
function collectTextureCss(styles: LintContext["styles"]): {
definedTextureClasses: Set<string>;
dropShadowRules: DropShadowRule[];
} {
const definedTextureClasses = new Set<string>();
const dropShadowRules: DropShadowRule[] = [];
const roots: postcss.Root[] = [];
for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
roots.push(root);
// fallow-ignore-next-line complexity
root.walkRules((rule) => {
const selectors = rule.selectors ?? [];
let hasMaskImage = false;
for (const node of rule.nodes ?? []) {
if (node.type !== "decl") continue;
const prop = node.prop.toLowerCase();
if (prop === "mask-image" || prop === "-webkit-mask-image") hasMaskImage = true;
}
if (hasMaskImage) {
for (const selector of selectors) {
for (const className of textureClassesInSelector(selector)) {
definedTextureClasses.add(className);
}
}
}
});
}
for (const root of roots) {
// fallow-ignore-next-line complexity
root.walkRules((rule) => {
const selectors = rule.selectors ?? [];
let hasDropShadow = false;
for (const node of rule.nodes ?? []) {
if (node.type !== "decl") continue;
if (node.prop.toLowerCase() === "filter" && /\bdrop-shadow\s*\(/i.test(node.value)) {
hasDropShadow = true;
}
}
if (hasDropShadow) {
for (const selector of selectors) {
const targetsBaseClass = /\.hf-texture-text\b/.test(selector);
const targetsDefinedTextureClass = textureClassesInSelector(selector).some((className) =>
definedTextureClasses.has(className),
);
dropShadowRules.push({
selector,
directlyTargetsTexture: targetsBaseClass || targetsDefinedTextureClass,
});
}
}
});
}
return { definedTextureClasses, dropShadowRules };
}
// fallow-ignore-next-line complexity
export const textureRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
({ tags, styles }) => {
const findings: HyperframeLintFinding[] = [];
const { definedTextureClasses, dropShadowRules } = collectTextureCss(styles);
for (const { selector, directlyTargetsTexture } of dropShadowRules) {
if (!directlyTargetsTexture) continue;
findings.push({
code: "texture_drop_shadow_on_text",
severity: "warning",
message: "Drop shadow is applied directly to textured text.",
selector,
fixHint:
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
});
}
for (const tag of tags) {
if (tag.name === "style" || tag.name === "script") continue;
const classes = classNames(tag);
if (classes.length === 0) continue;
const hasBaseClass = classes.includes(TEXTURE_BASE_CLASS);
const textureClasses = classes.filter(isTextureMaterialClass);
if (textureClasses.length > 0 && !hasBaseClass) {
findings.push({
code: "texture_class_missing_base",
severity: "warning",
message: `Texture material class \`${textureClasses[0]}\` is used without \`${TEXTURE_BASE_CLASS}\`.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint: `Add \`${TEXTURE_BASE_CLASS}\` alongside the material class, for example \`class="${TEXTURE_BASE_CLASS} ${textureClasses[0]}"\`.`,
snippet: truncateSnippet(tag.raw),
});
}
if (hasBaseClass && textureClasses.length === 0 && !hasInlineMaskImage(tag)) {
findings.push({
code: "texture_text_missing_mask",
severity: "warning",
message: `\`${TEXTURE_BASE_CLASS}\` is used without a texture material class or custom mask image.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Add a material class such as `hf-texture-lava`, or set `mask-image` and `-webkit-mask-image` on the element.",
snippet: truncateSnippet(tag.raw),
});
}
for (const textureClass of textureClasses) {
if (definedTextureClasses.has(textureClass)) continue;
findings.push({
code: "texture_class_unknown",
severity: "error",
message: `Texture material class \`${textureClass}\` is not defined by local CSS.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Paste the Texture Mask Text component `<style>...</style>` block into the composition, or fix the texture class typo.",
snippet: truncateSnippet(tag.raw),
});
}
if (hasBaseClass) {
for (const rule of dropShadowRules) {
if (rule.directlyTargetsTexture) continue;
if (!simpleSelectorMatchesTag(rule.selector, tag, classes)) continue;
findings.push({
code: "texture_drop_shadow_on_text",
severity: "warning",
message: "Drop shadow is applied directly to textured text.",
selector: rule.selector,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
snippet: truncateSnippet(tag.raw),
});
}
}
if (hasBaseClass && hasInlineDropShadow(tag)) {
findings.push({
code: "texture_drop_shadow_on_text",
severity: "warning",
message: "Drop shadow is applied directly to textured text.",
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
];
-40
View File
@@ -1,40 +0,0 @@
export type HyperframeLintSeverity = "error" | "warning" | "info";
export type HyperframeLintFinding = {
code: string;
severity: HyperframeLintSeverity;
message: string;
file?: string;
selector?: string;
elementId?: string;
fixHint?: string;
snippet?: string;
};
export type HyperframeLintResult = {
ok: boolean;
errorCount: number;
warningCount: number;
infoCount: number;
findings: HyperframeLintFinding[];
};
export type HyperframeLinterOptions = {
filePath?: string;
isSubComposition?: boolean;
externalStyles?: Array<{ href: string; content: string }>;
/**
* Set to `true` when linting compositions destined for distributed / Lambda
* rendering, where system-font capture (`allowSystemFontCapture`) is
* disabled. When `true`, the `system_font_will_alias` rule is elevated from
* `"info"` to `"warning"` because the alias substitution will NOT happen at
* render time the font will silently fall back to whatever the OS provides.
*/
distributed?: boolean;
};
// A rule is a function: receives parsed context, returns zero or more findings.
// Rules may be async (e.g. when lazy-loading heavy dependencies like recast).
export type LintRule<TContext> = (
ctx: TContext,
) => HyperframeLintFinding[] | Promise<HyperframeLintFinding[]>;
-300
View File
@@ -1,300 +0,0 @@
// 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;
};
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;
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(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)\s*=/i;
export const WINDOW_TIMELINE_ASSIGN_PATTERN =
/window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=\s*([A-Za-z_$][\w$]*)/i;
export const INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
const TIMELINE_REGISTRY_KEY_PATTERN =
/window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=/g;
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;
}
/**
* Find the `<html>` open tag in the source. Distinct from `findRootTag`,
* which returns the first element inside `<body>` the latter is "the
* composition's visible root", whereas `<html>` is where document-level
* metadata like `data-composition-variables` lives.
*/
export function findHtmlTag(source: string): OpenTag | null {
const match = /<html\b([^<>]*)>/i.exec(source);
if (!match) return null;
return {
raw: match[0],
name: "html",
attrs: match[1] ?? "",
index: match.index,
};
}
export function findRootTag(source: string): OpenTag | null {
const bodyOpenMatch = /<body\b([^>]*)>/i.exec(source);
const bodyCloseMatch = /<\/body>/i.exec(source);
if (
bodyOpenMatch &&
(readAttr(bodyOpenMatch[0], "data-composition-id") ||
readAttr(bodyOpenMatch[0], "data-width") ||
readAttr(bodyOpenMatch[0], "data-height"))
) {
return {
raw: bodyOpenMatch[0],
name: "body",
attrs: bodyOpenMatch[1] ?? "",
index: bodyOpenMatch.index,
};
}
const bodyStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
const bodyEnd =
bodyOpenMatch && bodyCloseMatch && bodyCloseMatch.index > bodyStart
? bodyCloseMatch.index
: source.length;
const bodyContent = bodyOpenMatch ? source.slice(bodyStart, bodyEnd) : source;
const bodyTags = extractOpenTags(bodyContent);
for (const tag of bodyTags) {
if (["script", "style", "meta", "link", "title"].includes(tag.name)) continue;
return { ...tag, index: tag.index + bodyStart };
}
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;
}
/**
* Read an attribute that may legitimately contain the opposite quote
* character. `readAttr` truncates `data-variable-values='{"title":"Hello"}'`
* at the first internal `"` because its `[^"']+` class excludes both quote
* types. This variant alternates: a double-quoted value never contains an
* unescaped `"`, and a single-quoted value never contains an unescaped `'`,
* so each branch can use a quote-specific class.
*
* Use for attributes whose values are JSON or otherwise carry the opposite
* quote character. Existing single-token attributes (`id`, `class`, etc.)
* stick with `readAttr` for consistency with the rest of the lint code.
*/
export function readJsonAttr(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"));
if (!match) return null;
return match[1] ?? match[2] ?? 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 extractTimelineRegistryKeys(source: string): string[] {
const keys = new Set<string>();
let match: RegExpExecArray | null;
const pattern = new RegExp(
TIMELINE_REGISTRY_KEY_PATTERN.source,
TIMELINE_REGISTRY_KEY_PATTERN.flags,
);
while ((match = pattern.exec(source)) !== null) {
const key = match[1] ?? match[2];
if (key) keys.add(key);
}
return [...keys];
}
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);
}
}
// fallow-ignore-next-line complexity
export function stripJsComments(source: string): string {
let out = "";
let i = 0;
let quote: "'" | '"' | "`" | null = null;
let escaped = false;
while (i < source.length) {
const ch = source[i] ?? "";
const next = source[i + 1] ?? "";
if (quote) {
out += ch;
if (escaped) {
escaped = false;
} else if (ch === "\\") {
escaped = true;
} else if (ch === quote) {
quote = null;
}
i += 1;
continue;
}
if (ch === "'" || ch === '"' || ch === "`") {
quote = ch;
out += ch;
i += 1;
continue;
}
if (ch === "/" && next === "/") {
out += " ";
i += 2;
while (i < source.length && source[i] !== "\n" && source[i] !== "\r") {
out += " ";
i += 1;
}
continue;
}
if (ch === "/" && next === "*") {
out += " ";
i += 2;
while (i < source.length) {
const blockCh = source[i] ?? "";
const blockNext = source[i + 1] ?? "";
if (blockCh === "*" && blockNext === "/") {
out += " ";
i += 2;
break;
}
out += blockCh === "\n" || blockCh === "\r" ? blockCh : " ";
i += 1;
}
continue;
}
out += ch;
i += 1;
}
return out;
}
// One linear pass that drops every `<!-- … -->` region. Uses indexOf, not a
// `/<!--[\s\S]*?-->/` regex: that pattern backtracks O(n²) on inputs with many
// unterminated "<!--" (CodeQL js/polynomial-redos). An unterminated "<!--" with
// no closing "-->" is kept verbatim, matching the prior regex's no-match behavior.
function stripHtmlCommentsOnce(source: string): string {
let out = "";
let i = 0;
for (;;) {
const start = source.indexOf("<!--", i);
if (start < 0) return out + source.slice(i);
const end = source.indexOf("-->", start + 4);
if (end < 0) return out + source.slice(i);
out += source.slice(i, start);
i = end + 3;
}
}
// Strip HTML comments to a fixpoint. A single pass is not enough: deleting one
// comment can splice adjacent markers into a fresh, complete <!-- … --> (e.g.
// "<<!-- -->!-- … -->" → "<!-- … -->"), which would otherwise survive and let a
// commented-out <template>/tag hijack the linter's tag scan.
export function stripHtmlComments(source: string): string {
let out = source;
for (let prev = ""; prev !== out; ) {
prev = out;
out = stripHtmlCommentsOnce(out);
}
return out;
}
export function extractScriptTextsAndSrcs(scripts: ExtractedBlock[]): {
texts: string[];
srcs: string[];
} {
const texts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
const srcs = scripts.map((s) => readAttr(`<script ${s.attrs}>`, "src") || "").filter(Boolean);
return { texts, srcs };
}
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)}...`;
}
+1
View File
@@ -1,2 +1,3 @@
export * from "./slideshow.types";
export * from "./parseSlideshow";
export { isSceneLikeCompositionId } from "./sceneId";