mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
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:
@@ -1,4 +1,4 @@
|
||||
import { lintHyperframeHtml } from "../lint/hyperframeLinter";
|
||||
import { lintHyperframeHtml } from "@hyperframes/lint";
|
||||
|
||||
export type HyperframeStaticFailureReason =
|
||||
| "missing_composition_id"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
];
|
||||
},
|
||||
];
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(/&/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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
];
|
||||
@@ -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[]>;
|
||||
@@ -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,2 +1,3 @@
|
||||
export * from "./slideshow.types";
|
||||
export * from "./parseSlideshow";
|
||||
export { isSceneLikeCompositionId } from "./sceneId";
|
||||
|
||||
Reference in New Issue
Block a user