refactor(core): simplify packages/core — dead code, dedup, type safety (#1413)

- Delete unused mediaPreloader module, 5 dead RuntimeState fields,
  emitPerformanceMetric, lintScriptUrls, 5 variable type guards
- Consolidate compiler utilities: unify CSS URL regex, relative URL
  predicate, MIME map, @import regex, bulk asset rewrite delegation
- Cache extractGsapWindows per script (eliminates 2 redundant recast
  parses per lint run), share stripJsComments and script extraction
- Deduplicate GSAP parser: share serializeValue/safeJsKey, centralize
  converted-id fallback (6 sites), keyframe codegen (3 sites),
  waypoint extraction, insert-after-anchor, script hoisting
- Replace 88 bare any annotations with typed AstNode/AstPath interfaces
- Derive RuntimeBridgeControlAction from HyperframeControlAction,
  alias RuntimePickerElementInfo, share macOS font profiler
- Gate generateHyperframesStyles on includeStyles, collapse 4 GSAP
  property mutation cases into 2
- Extract magic numbers into named constants, replace 5 double casts
  with type guards and typed accessors (runtime/globals.ts),
  reduce function complexity in htmlParser and files route
This commit is contained in:
Miguel Ángel
2026-06-13 18:23:36 -04:00
committed by GitHub
parent fbc3cdf2fd
commit 6f677292ae
29 changed files with 551 additions and 1399 deletions
+106 -108
View File
@@ -1,5 +1,6 @@
import { readFileSync, existsSync } from "fs";
import { join, resolve, relative, dirname, isAbsolute, sep } from "path";
import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
import { transformSync } from "esbuild";
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
import {
@@ -72,14 +73,7 @@ function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" =
}
function isRelativeUrl(url: string): boolean {
if (!url) return false;
return (
!url.startsWith("http://") &&
!url.startsWith("https://") &&
!url.startsWith("//") &&
!url.startsWith("data:") &&
!isAbsolute(url)
);
return !isNonRelativeUrl(url) && !isAbsolute(url);
}
function safeReadFile(filePath: string): string | null {
@@ -94,8 +88,6 @@ function safeReadFile(filePath: string): string | null {
const CSS_IMPORT_RE =
/@import\s+(?:url\(\s*(["']?)([^)"']+)\1\s*\)|(["'])([^"']+)\3)\s*([^;]*);\s*/g;
const REBASE_URL_RE = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
const CSS_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
function withCommentsStripped<T>(
@@ -123,7 +115,7 @@ function rebaseCssUrls(css: string, cssFileDir: string, projectDir: string): str
const resolvedRoot = resolve(projectDir);
const resolvedDir = resolve(cssFileDir);
if (resolvedDir === resolvedRoot) return css;
return css.replace(REBASE_URL_RE, (full, quote: string, urlValue: string) => {
return css.replace(CSS_URL_RE, (full, quote: string, urlValue: string) => {
if (!urlValue || !isRelativeUrl(urlValue)) return full;
const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
if (!basePath) return full;
@@ -205,29 +197,24 @@ function appendSuffixToUrl(baseUrl: string, suffix: string): string {
return baseUrl;
}
function guessMimeType(filePath: string): string {
const l = filePath.toLowerCase();
if (l.endsWith(".svg")) return "image/svg+xml";
if (l.endsWith(".json")) return "application/json";
if (l.endsWith(".txt")) return "text/plain";
if (l.endsWith(".xml")) return "application/xml";
return "application/octet-stream";
}
function shouldInlineAsDataUrl(filePath: string): boolean {
const l = filePath.toLowerCase();
return l.endsWith(".svg") || l.endsWith(".json") || l.endsWith(".txt") || l.endsWith(".xml");
}
const INLINE_MIME: Record<string, string> = {
".svg": "image/svg+xml",
".json": "application/json",
".txt": "text/plain",
".xml": "application/xml",
};
function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): string | null {
if (!urlValue || !isRelativeUrl(urlValue)) return null;
const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
if (!basePath) return null;
const filePath = resolveWithinProject(projectDir, basePath);
if (!filePath || !shouldInlineAsDataUrl(filePath)) return null;
if (!filePath) return null;
const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0] ?? "";
const mimeType = INLINE_MIME[ext];
if (!mimeType) return null;
const content = safeReadFileBuffer(filePath);
if (content == null) return null;
const mimeType = guessMimeType(filePath);
const dataUrl = `data:${mimeType};base64,${content.toString("base64")}`;
return appendSuffixToUrl(dataUrl, suffix);
}
@@ -479,14 +466,13 @@ function autoHealMissingCompositionIds(document: Document): void {
function coalesceHeadStylesAndBodyScripts(document: Document): void {
const headStyleEls = [...document.querySelectorAll("head style")];
if (headStyleEls.length > 1) {
const importRe = /@import\s+url\([^)]*\)\s*;|@import\s+["'][^"']+["']\s*;/gi;
const imports: string[] = [];
const cssParts: string[] = [];
const seenImports = new Set<string>();
for (const el of headStyleEls) {
const raw = (el.textContent || "").trim();
if (!raw) continue;
const nonImportCss = raw.replace(importRe, (match) => {
const nonImportCss = raw.replace(CSS_IMPORT_RE, (match) => {
const cleaned = match.trim();
if (!seenImports.has(cleaned)) {
seenImports.add(cleaned);
@@ -607,6 +593,78 @@ export interface BundleOptions {
* - Inlines sub-composition HTML fragments (data-composition-src)
* - Inlines small textual assets as data URLs
*/
function ensureExternalScriptTag(doc: Document, src: string): void {
if (doc.querySelector(`script[src="${src}"]`)) return;
const el = doc.createElement("script");
el.setAttribute("src", src);
doc.body.appendChild(el);
}
function hoistExternalScript(
src: string,
projectDir: string,
doc: Document,
seenSrcs: Set<string>,
chunks: string[],
): void {
if (seenSrcs.has(src)) return;
seenSrcs.add(src);
if (!isNonRelativeUrl(src) && !isAbsolute(src)) {
const jsPath = resolveWithinProject(projectDir, src);
const js = jsPath ? safeReadFile(jsPath) : null;
if (js != null) {
chunks.push(js);
return;
}
}
ensureExternalScriptTag(doc, src);
}
function hoistCompositionScripts(
container: { querySelectorAll: (sel: string) => NodeListOf<Element> },
opts: {
projectDir: string;
document: Document;
compId: string | null;
runtimeScope: string | undefined;
runtimeCompId: string | undefined;
authoredRootId: string | undefined;
seenCompScriptSrcs: Set<string>;
compScriptChunks: string[];
},
): void {
for (const scriptEl of [...container.querySelectorAll("script")]) {
const externalSrc = (scriptEl.getAttribute("src") || "").trim();
if (externalSrc) {
hoistExternalScript(
externalSrc,
opts.projectDir,
opts.document,
opts.seenCompScriptSrcs,
opts.compScriptChunks,
);
} else {
opts.compScriptChunks.push(
opts.compId
? wrapScopedCompositionScript(
scriptEl.textContent || "",
opts.compId,
"[HyperFrames] composition script error:",
opts.runtimeScope,
opts.runtimeCompId || opts.compId,
opts.authoredRootId,
)
: wrapInlineScriptWithErrorBoundary(
scriptEl.textContent || "",
"[HyperFrames] composition script error:",
),
);
}
scriptEl.remove();
}
}
export async function bundleToSingleHtml(
projectDir: string,
options?: BundleOptions,
@@ -789,47 +847,16 @@ export async function bundleToSingleHtml(
);
styleEl.remove();
}
// Hoist scripts into the collected script chunks
for (const scriptEl of [...innerRoot.querySelectorAll("script")]) {
const externalSrc = (scriptEl.getAttribute("src") || "").trim();
if (externalSrc) {
if (!seenCompScriptSrcs.has(externalSrc)) {
seenCompScriptSrcs.add(externalSrc);
if (isRelativeUrl(externalSrc)) {
const jsPath = resolveWithinProject(projectDir, externalSrc);
const js = jsPath ? safeReadFile(jsPath) : null;
if (js != null) {
compScriptChunks.push(js);
} else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
const extScript = document.createElement("script");
extScript.setAttribute("src", externalSrc);
document.body.appendChild(extScript);
}
} else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
const extScript = document.createElement("script");
extScript.setAttribute("src", externalSrc);
document.body.appendChild(extScript);
}
}
} else {
compScriptChunks.push(
compId
? wrapScopedCompositionScript(
scriptEl.textContent || "",
compId,
"[HyperFrames] composition script error:",
runtimeScope,
runtimeCompId || compId,
authoredRootId,
)
: wrapInlineScriptWithErrorBoundary(
scriptEl.textContent || "",
"[HyperFrames] composition script error:",
),
);
}
scriptEl.remove();
}
hoistCompositionScripts(innerRoot, {
projectDir,
document,
compId,
runtimeScope,
runtimeCompId,
authoredRootId: authoredRootId ?? undefined,
seenCompScriptSrcs,
compScriptChunks,
});
// Copy dimension attributes from inner root to host if not already set
const innerW = innerRoot.getAttribute("data-width");
@@ -845,45 +872,16 @@ export async function bundleToSingleHtml(
compStyleChunks.push(compId ? scopeCssToComposition(css, compId, runtimeScope) : css);
styleEl.remove();
}
for (const scriptEl of [...innerDoc.querySelectorAll("script")]) {
const externalSrc = (scriptEl.getAttribute("src") || "").trim();
if (externalSrc) {
if (!seenCompScriptSrcs.has(externalSrc)) {
seenCompScriptSrcs.add(externalSrc);
if (isRelativeUrl(externalSrc)) {
const jsPath = resolveWithinProject(projectDir, externalSrc);
const js = jsPath ? safeReadFile(jsPath) : null;
if (js != null) {
compScriptChunks.push(js);
} else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
const extScript = document.createElement("script");
extScript.setAttribute("src", externalSrc);
document.body.appendChild(extScript);
}
} else if (!document.querySelector(`script[src="${externalSrc}"]`)) {
const extScript = document.createElement("script");
extScript.setAttribute("src", externalSrc);
document.body.appendChild(extScript);
}
}
} else {
compScriptChunks.push(
compId
? wrapScopedCompositionScript(
scriptEl.textContent || "",
compId,
"[HyperFrames] composition script error:",
runtimeScope,
runtimeCompId || compId,
)
: wrapInlineScriptWithErrorBoundary(
scriptEl.textContent || "",
"[HyperFrames] composition script error:",
),
);
}
scriptEl.remove();
}
hoistCompositionScripts(innerDoc, {
projectDir,
document,
compId,
runtimeScope,
runtimeCompId,
authoredRootId: undefined,
seenCompScriptSrcs,
compScriptChunks,
});
host.innerHTML = innerDoc.body.innerHTML || "";
}
@@ -67,18 +67,12 @@ export function rewriteAssetPaths<T>(
getAttr: (el: T, attr: string) => string | null | undefined,
setAttr: (el: T, attr: string, value: string) => void,
): void {
const compDir = dirname(compSrcPath);
if (!compDir || compDir === ".") return;
for (const el of elements) {
for (const attr of PATH_ATTRS) {
const val = (getAttr(el, attr) || "").trim();
if (isAbsoluteOrSpecial(val)) continue;
if (!needsRewrite(val)) continue;
const rewritten = join(compDir, val);
const normalized = resolve("/", rewritten).slice(1);
if (normalized !== val) {
setAttr(el, attr, normalized);
const rewritten = rewriteAssetPath(compSrcPath, val);
if (rewritten !== val) {
setAttr(el, attr, rewritten);
}
}
}
-20
View File
@@ -330,26 +330,6 @@ export interface CompositionSpec {
variables: CompositionVariable[];
}
export function isStringVariable(v: CompositionVariable): v is StringVariable {
return v.type === "string";
}
export function isNumberVariable(v: CompositionVariable): v is NumberVariable {
return v.type === "number";
}
export function isColorVariable(v: CompositionVariable): v is ColorVariable {
return v.type === "color";
}
export function isBooleanVariable(v: CompositionVariable): v is BooleanVariable {
return v.type === "boolean";
}
export function isEnumVariable(v: CompositionVariable): v is EnumVariable {
return v.type === "enum";
}
export type TimelineElement =
| TimelineMediaElement
| TimelineTextElement
+9 -2
View File
@@ -4,6 +4,8 @@ import { homedir, platform } from "node:os";
import { join, resolve } from "node:path";
export const SYSTEM_FONT_SIZE_LIMIT = 5 * 1024 * 1024;
const PROFILER_TIMEOUT_MS = 5000;
const FC_MATCH_TIMEOUT_MS = 3000;
export type FontFileFormat = "ttf" | "otf" | "woff2" | "woff" | "ttc";
@@ -238,7 +240,7 @@ function getSystemProfilerIndex(): Map<string, SystemProfilerEntry[]> {
const raw = execFileSync("system_profiler", ["SPFontsDataType", "-json"], {
encoding: "utf8",
maxBuffer: 12 * 1024 * 1024,
timeout: 5000,
timeout: PROFILER_TIMEOUT_MS,
});
const parsed = JSON.parse(raw);
if (!parsed?.SPFontsDataType || !Array.isArray(parsed.SPFontsDataType)) return profilerCache;
@@ -289,7 +291,7 @@ function locateViaFcMatch(targetFamily: string): LocatedFont | null {
try {
const result = execFileSync("fc-match", [targetFamily, "--format=%{file}"], {
encoding: "utf8",
timeout: 3000,
timeout: FC_MATCH_TIMEOUT_MS,
}).trim();
if (!result || !isRegularFile(result) || !isPathBounded(result)) return null;
const fileName = result.split("/").pop() ?? "";
@@ -403,6 +405,11 @@ function dedupeVariants(variants: LocatedFontVariant[]): LocatedFontVariant[] {
return Array.from(seen.values());
}
export function getSystemProfilerFamilies(): string[] {
const index = getSystemProfilerIndex();
return Array.from(index.keys());
}
export function clearSystemFontCache(): void {
cache.clear();
profilerCache = null;
+21 -23
View File
@@ -320,11 +320,26 @@ export function generateHyperframesHtml(
? ` data-zoom-keyframes='${JSON.stringify(stageZoomKeyframes).replace(/'/g, "&#39;")}'`
: "";
const { coreCss, customCss, googleFontsLink } = generateHyperframesStyles(
sortedElements,
resolution,
customStyles,
);
let styleTags = "";
let googleFontsLink = "";
if (includeStyles) {
const styles = generateHyperframesStyles(sortedElements, resolution, customStyles);
googleFontsLink = styles.googleFontsLink;
styleTags = [
styles.coreCss
? ` <style data-hf-core="true">
${styles.coreCss.split("\n").join("\n ")}
</style>`
: "",
styles.customCss
? ` <style data-hf-custom="true">
${styles.customCss.split("\n").join("\n ")}
</style>`
: "",
]
.filter(Boolean)
.join("\n");
}
const gsapScript = includeScripts
? generateGsapTimelineScript(sortedElements, totalDuration, {
@@ -344,23 +359,6 @@ ${gsapScript}
</script>`
: "";
const styleTags = includeStyles
? [
coreCss
? ` <style data-hf-core="true">
${coreCss.split("\n").join("\n ")}
</style>`
: "",
customCss
? ` <style data-hf-custom="true">
${customCss.split("\n").join("\n ")}
</style>`
: "",
]
.filter(Boolean)
.join("\n")
: "";
const customStylesAttr = customStyles
? ` data-custom-styles='${JSON.stringify(customStyles).replace(/'/g, "&#39;")}'`
: "";
@@ -372,7 +370,7 @@ ${gsapScript}
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${includeStyles ? googleFontsLink : ""}
${googleFontsLink}
${gsapCdnTag}
${styleTags ? ` ${styleTags}` : ""}
</head>
-8
View File
@@ -67,14 +67,6 @@ describe("@hyperframes/core public API exports", () => {
expect(zoom.focusX).toBe(960);
expect(zoom.focusY).toBe(540);
});
it("exports composition variable type guards", () => {
expect(typeof core.isStringVariable).toBe("function");
expect(typeof core.isNumberVariable).toBe("function");
expect(typeof core.isColorVariable).toBe("function");
expect(typeof core.isBooleanVariable).toBe("function");
expect(typeof core.isEnumVariable).toBe("function");
});
});
describe("template exports", () => {
-5
View File
@@ -53,11 +53,6 @@ export {
isMediaElement,
isCompositionElement,
getDefaultStageZoom,
isStringVariable,
isNumberVariable,
isColorVariable,
isBooleanVariable,
isEnumVariable,
} from "./core.types";
// Templates
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { lintHyperframeHtml, lintScriptUrls } from "./hyperframeLinter.js";
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "./hyperframeLinter.js";
describe("lintHyperframeHtml — orchestrator", () => {
const validComposition = `
@@ -64,66 +64,3 @@ describe("lintHyperframeHtml — orchestrator", () => {
expect(missing).toHaveLength(0);
});
});
describe("lintScriptUrls", () => {
it("reports error for script URL returning non-2xx", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
vi.stubGlobal("fetch", mockFetch);
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://unpkg.com/@hyperframe/player@latest/dist/player.js"></script>
</body></html>`;
const findings = await lintScriptUrls(html);
const finding = findings.find((f) => f.code === "inaccessible_script_url");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("404");
vi.unstubAllGlobals();
});
it("reports error for unreachable script URL", async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error("AbortError"));
vi.stubGlobal("fetch", mockFetch);
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://example.invalid/nonexistent.js"></script>
</body></html>`;
const findings = await lintScriptUrls(html);
const finding = findings.find((f) => f.code === "inaccessible_script_url");
expect(finding).toBeDefined();
vi.unstubAllGlobals();
});
it("does not flag accessible script URLs", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal("fetch", mockFetch);
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
</body></html>`;
const findings = await lintScriptUrls(html);
expect(findings.length).toBe(0);
vi.unstubAllGlobals();
});
it("skips inline scripts without src", async () => {
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>console.log("inline")</script>
</body></html>`;
const findings = await lintScriptUrls(html);
expect(findings.length).toBe(0);
expect(mockFetch).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
});
@@ -151,82 +151,3 @@ export async function lintMediaUrls(
await Promise.all(checks);
return findings;
}
function extractScriptUrls(html: string): Array<{ url: string; snippet: string }> {
const results: Array<{ url: string; snippet: string }> = [];
const scriptRe = /<script\b[^>]*>/gi;
let match: RegExpExecArray | null;
while ((match = scriptRe.exec(html)) !== null) {
const raw = match[0];
const src = readAttr(raw, "src");
if (!src) continue;
if (/^https?:\/\//i.test(src)) {
results.push({
url: src,
snippet: truncateSnippet(raw) ?? "",
});
}
}
return results;
}
/**
* Async lint pass: HEAD-checks every external script 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 lintScriptUrls(
html: string,
options: { timeoutMs?: number } = {},
): Promise<HyperframeLintFinding[]> {
const urls = extractScriptUrls(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, 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_script_url",
severity: "error",
message: `<script> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 120)}`,
fixHint:
"This script URL is not accessible. Remove it or replace with a valid URL. The HyperFrames runtime is injected automatically — do not load it manually.",
snippet,
});
}
} catch (err) {
const reason = err instanceof Error ? err.name : "unknown";
findings.push({
code: "inaccessible_script_url",
severity: "error",
message: `<script> references an unreachable URL (${reason}): ${url.slice(0, 120)}`,
fixHint: "This script URL is not accessible. Remove it or replace with a valid URL.",
snippet,
});
}
});
await Promise.all(checks);
return findings;
}
+9 -15
View File
@@ -1,19 +1,16 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import { readAttr } from "../utils";
import { readAttr, extractScriptTextsAndSrcs } from "../utils";
export const adapterRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// missing_lottie_script
({ tags, scripts }) => {
const allScriptTexts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
const allScriptSrcs = scripts
.map((s) => readAttr(`<script ${s.attrs}>`, "src") || "")
.filter(Boolean);
const { texts, srcs } = extractScriptTextsAndSrcs(scripts);
const hasLottieAttr = tags.some((t) => readAttr(t.raw, "data-lottie-src") !== null);
const usesLottieApi = allScriptTexts.some((t) =>
const usesLottieApi = texts.some((t) =>
/lottie\.(loadAnimation|setSpeed|play|stop|destroy)\b/.test(t),
);
const hasLottieScript = allScriptSrcs.some((src) => /lottie/i.test(src));
const hasLottieScript = srcs.some((src) => /lottie/i.test(src));
if (!(hasLottieAttr || usesLottieApi) || hasLottieScript) return [];
return [
@@ -30,19 +27,16 @@ export const adapterRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
// missing_three_script
({ scripts }) => {
const allScriptTexts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
const allScriptSrcs = scripts
.map((s) => readAttr(`<script ${s.attrs}>`, "src") || "")
.filter(Boolean);
const { texts, srcs } = extractScriptTextsAndSrcs(scripts);
const usesThree = allScriptTexts.some((t) => /\bTHREE\./.test(t));
const hasThreeScript = allScriptSrcs.some((src) => /three/i.test(src));
const hasThreeImportMap = allScriptTexts.some(
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 = allScriptTexts.some(
const hasThreeModuleImport = texts.some(
(t) => /\bimport\b.*['"]three['"]/.test(t) || /\bfrom\s+['"]three['"]/.test(t),
);
+3 -4
View File
@@ -1,5 +1,5 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import { findHtmlTag, readAttr, readJsonAttr, truncateSnippet } from "../utils";
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
@@ -397,8 +397,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
// Strip comments to avoid false positives
const stripped = script.content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
const stripped = stripJsComments(script.content);
if (/requestAnimationFrame\s*\(/.test(stripped)) {
findings.push({
code: "requestanimationframe_in_composition",
@@ -515,7 +514,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
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 as string)) missing.push("type");
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) {
+2 -2
View File
@@ -3,6 +3,7 @@ import postcss from "postcss";
import {
readAttr,
truncateSnippet,
stripJsComments,
extractCompositionIdsFromCss,
extractTimelineRegistryKeys,
getInlineScriptSyntaxError,
@@ -307,8 +308,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
];
for (const script of scripts) {
// Strip comments to avoid false positives
const stripped = script.content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
const stripped = stripJsComments(script.content);
for (const { pattern, label, hint } of patterns) {
if (pattern.test(stripped)) {
findings.push({
+14 -72
View File
@@ -25,6 +25,7 @@ import type { OpenTag } from "../utils";
import {
readAttr,
truncateSnippet,
stripJsComments,
WINDOW_TIMELINE_ASSIGN_PATTERN,
TIMELINE_REGISTRY_ASSIGN_PATTERN,
} from "../utils";
@@ -52,71 +53,6 @@ const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
// ── GSAP parsing utilities ─────────────────────────────────────────────────
// fallow-ignore-next-line complexity
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;
}
function countClassUsage(tags: OpenTag[]): Map<string, number> {
const counts = new Map<string, number>();
for (const tag of tags) {
@@ -163,10 +99,16 @@ function synthesizeWindowRaw(
return `${timelineVar}.${anim.method}("${anim.targetSelector}", { ${entries.join(", ")} }, ${pos})`;
}
// Build lint windows straight from the parser's structured animations. The
// parser already resolves variable targets (`tl.to(kicker, …)`) to selectors
// and excludes non-DOM object-target anchors (`tl.to({ _: 0 }, …)`), so there's
// no fragile positional pairing between a regex walk and the parsed list.
const gsapWindowsCache = new Map<string, GsapWindow[]>();
async function cachedExtractGsapWindows(scriptContent: string): Promise<GsapWindow[]> {
const cached = gsapWindowsCache.get(scriptContent);
if (cached) return cached;
const windows = await extractGsapWindows(scriptContent);
gsapWindowsCache.set(scriptContent, windows);
return windows;
}
// fallow-ignore-next-line complexity
async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
if (!/gsap\.timeline/.test(script)) return [];
@@ -414,7 +356,7 @@ export const gsapRules: LintRule<LintContext>[] = [
for (const script of scripts) {
const localTimelineCompId = readRegisteredTimelineCompositionId(script.content);
const gsapWindows = await extractGsapWindows(script.content);
const gsapWindows = await cachedExtractGsapWindows(script.content);
const clipStartBoundaries =
clipStartBoundariesByComposition.get(localTimelineCompId || rootCompositionId || "") ?? [];
@@ -562,7 +504,7 @@ export const gsapRules: LintRule<LintContext>[] = [
for (const script of scripts) {
if (!/gsap\.timeline/.test(script.content)) continue;
const windows = await extractGsapWindows(script.content);
const windows = await cachedExtractGsapWindows(script.content);
type Conflict = { cssTransform: string; props: Set<string>; raw: string };
const conflicts = new Map<string, Conflict>();
@@ -853,7 +795,7 @@ export const gsapRules: LintRule<LintContext>[] = [
for (const script of scripts) {
if (!/gsap\.timeline/.test(script.content)) continue;
const windows = await extractGsapWindows(script.content);
const windows = await cachedExtractGsapWindows(script.content);
for (const win of windows) {
if (win.method !== "from") continue;
+74
View File
@@ -171,6 +171,80 @@ export function getInlineScriptSyntaxError(source: string): string | null {
}
}
// 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;
}
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";
}
+220 -227
View File
@@ -18,6 +18,8 @@ import {
type GsapMethod,
type GsapPercentageKeyframe,
type ParsedGsap,
serializeValue as valueToCode,
safeJsKey as safeKey,
} from "./gsapSerialize";
export type {
@@ -52,6 +54,21 @@ export type { SpringPreset } from "./springEase";
const GSAP_METHODS = new Set<string>(["set", "to", "from", "fromTo"]);
// ── Recast / Babel AST shape types ────────────────────────────────────────
//
// Recast's own typings are loose (`any` everywhere). These local shapes
// capture the properties we actually access, giving us IDE navigation and
// catch-at-write-time safety without depending on @babel/types at runtime.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- recast AST nodes are inherently untyped
interface AstNode extends Record<string, any> {
type: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- recast visitor paths are inherently untyped
interface AstPath extends Record<string, any> {
node: AstNode;
}
// ── Recast AST Helpers ──────────────────────────────────────────────────────
type ScopeBindings = ReadonlyMap<string, number | string | boolean>;
@@ -66,10 +83,10 @@ function parseScript(script: string) {
});
}
function collectScopeBindings(ast: any): ScopeBindings {
function collectScopeBindings(ast: AstNode): ScopeBindings {
const bindings = new Map<string, number | string | boolean>();
recast.types.visit(ast, {
visitVariableDeclarator(path: any) {
visitVariableDeclarator(path: AstPath) {
const name = path.node.id?.name;
const init = path.node.init;
if (name && init) {
@@ -83,7 +100,7 @@ function collectScopeBindings(ast: any): ScopeBindings {
}
function resolveNode(
node: any,
node: AstNode | undefined,
scope: ReadonlyMap<string, number | string | boolean>,
): number | string | boolean | undefined {
if (!node) return undefined;
@@ -127,7 +144,7 @@ function resolveNode(
return undefined;
}
function extractLiteralValue(node: any, scope: ScopeBindings): unknown {
function extractLiteralValue(node: AstNode | undefined, scope: ScopeBindings): unknown {
return resolveNode(node, scope);
}
@@ -156,7 +173,7 @@ const SCOPE_NODE_TYPES = new Set([
* `gsap.utils.toArray(".sel")` return the CSS selector it resolves to.
* `getElementById("id")` maps to `#id`. Returns null for anything else.
*/
function selectorFromQueryCall(node: any, scope: ScopeBindings): string | null {
function selectorFromQueryCall(node: AstNode, scope: ScopeBindings): string | null {
if (node?.type !== "CallExpression") return null;
const callee = node.callee;
if (callee?.type !== "MemberExpression" || callee.property?.type !== "Identifier") return null;
@@ -169,7 +186,7 @@ function selectorFromQueryCall(node: any, scope: ScopeBindings): string | null {
}
/** The nearest enclosing function/program node — the binding scope of `path`. */
function enclosingScopeNode(path: any): any {
function enclosingScopeNode(path: AstPath): AstNode | null {
let p = path?.parentPath;
while (p) {
if (SCOPE_NODE_TYPES.has(p.node?.type)) return p.node;
@@ -179,8 +196,8 @@ function enclosingScopeNode(path: any): any {
}
/** Scope nodes enclosing `path`, innermost first. */
function scopeChainOf(path: any): any[] {
const chain: any[] = [];
function scopeChainOf(path: AstPath): AstNode[] {
const chain: AstNode[] = [];
let p = path;
while (p) {
if (SCOPE_NODE_TYPES.has(p.node?.type)) chain.push(p.node);
@@ -194,7 +211,7 @@ type TargetBindings = Map<any, Map<string, string>>;
function addBinding(
bindings: TargetBindings,
scopeNode: any,
scopeNode: AstNode,
name: string,
selector: string,
): void {
@@ -212,21 +229,23 @@ function addBinding(
* (2) iteration callback params (`coll.forEach(el => …)`), whose element type is
* the collection's selector resolved against the pass-1 bindings.
*/
function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings {
function collectTargetBindings(ast: AstNode, scope: ScopeBindings): TargetBindings {
const bindings: TargetBindings = new Map();
recast.types.visit(ast, {
visitVariableDeclarator(path: any) {
visitVariableDeclarator(path: AstPath) {
const name = path.node.id?.name;
const selector = selectorFromQueryCall(path.node.init, scope);
if (name && selector !== null) addBinding(bindings, enclosingScopeNode(path), name, selector);
const scopeNode = enclosingScopeNode(path);
if (name && selector !== null && scopeNode) addBinding(bindings, scopeNode, name, selector);
this.traverse(path);
},
visitAssignmentExpression(path: any) {
visitAssignmentExpression(path: AstPath) {
const left = path.node.left;
const selector = selectorFromQueryCall(path.node.right, scope);
if (left?.type === "Identifier" && selector !== null) {
addBinding(bindings, enclosingScopeNode(path), left.name, selector);
const scopeNode = enclosingScopeNode(path);
if (left?.type === "Identifier" && selector !== null && scopeNode) {
addBinding(bindings, scopeNode, left.name, selector);
}
this.traverse(path);
},
@@ -234,7 +253,7 @@ function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings {
// Pass 2: forEach/map callback params take the collection's selector.
recast.types.visit(ast, {
visitCallExpression(path: any) {
visitCallExpression(path: AstPath) {
const node = path.node;
const callee = node.callee;
if (
@@ -256,7 +275,7 @@ function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings {
return bindings;
}
function isFunctionNode(node: any): boolean {
function isFunctionNode(node: AstNode): boolean {
return (
node?.type === "ArrowFunctionExpression" ||
node?.type === "FunctionExpression" ||
@@ -266,8 +285,8 @@ function isFunctionNode(node: any): boolean {
/** Resolve the selector a `.forEach`/`.map` is iterating over (variable or inline call). */
function resolveCollectionSelector(
node: any,
callPath: any,
node: AstNode,
callPath: AstPath,
scope: ScopeBindings,
bindings: TargetBindings,
): string | null {
@@ -277,7 +296,7 @@ function resolveCollectionSelector(
}
/** Resolve a variable name to its selector using the lexical scope chain of `path`. */
function lookupBinding(name: string, path: any, bindings: TargetBindings): string | null {
function lookupBinding(name: string, path: AstPath, bindings: TargetBindings): string | null {
for (const scopeNode of scopeChainOf(path)) {
const selector = bindings.get(scopeNode)?.get(name);
if (selector !== undefined) return selector;
@@ -294,8 +313,8 @@ function lookupBinding(name: string, path: any, bindings: TargetBindings): strin
* runtime-computed selector).
*/
function resolveTargetSelector(
node: any,
path: any,
node: AstNode,
path: AstPath,
scope: ScopeBindings,
bindings: TargetBindings,
): string | null {
@@ -311,7 +330,7 @@ function resolveTargetSelector(
}
if (node.type === "ArrayExpression") {
const parts = node.elements
.map((el: any) => resolveTargetSelector(el, path, scope, bindings))
.map((el: AstNode) => resolveTargetSelector(el, path, scope, bindings))
.filter((s: string | null): s is string => typeof s === "string" && s.length > 0);
return parts.length > 0 ? parts.join(", ") : null;
}
@@ -322,7 +341,7 @@ function resolveTargetSelector(
return null;
}
function objectExpressionToRecord(node: any, scope: ScopeBindings): Record<string, unknown> {
function objectExpressionToRecord(node: AstNode, scope: ScopeBindings): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (node?.type !== "ObjectExpression") return result;
for (const prop of node.properties ?? []) {
@@ -342,7 +361,7 @@ function objectExpressionToRecord(node: any, scope: ScopeBindings): Record<strin
// ── Timeline Variable Detection ─────────────────────────────────────────────
function isGsapTimelineCall(node: any): boolean {
function isGsapTimelineCall(node: AstNode): boolean {
return (
node?.type === "CallExpression" &&
node.callee?.type === "MemberExpression" &&
@@ -363,13 +382,13 @@ interface TimelineDetection {
}
function extractTimelineDefaults(
callNode: any,
callNode: AstNode,
scope: ScopeBindings,
): TimelineDefaults | undefined {
const arg = callNode.arguments?.[0];
if (!arg || arg.type !== "ObjectExpression") return undefined;
const defaultsProp = arg.properties?.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === "defaults",
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "defaults",
);
if (!defaultsProp?.value || defaultsProp.value.type !== "ObjectExpression") return undefined;
const record = objectExpressionToRecord(defaultsProp.value, scope);
@@ -379,13 +398,13 @@ function extractTimelineDefaults(
return Object.keys(result).length > 0 ? result : undefined;
}
function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection {
function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection {
let timelineVar: string | null = null;
let timelineCount = 0;
let defaults: TimelineDefaults | undefined;
const emptyScope: ScopeBindings = scope ?? new Map();
recast.types.visit(ast, {
visitVariableDeclarator(path: any) {
visitVariableDeclarator(path: AstPath) {
if (isGsapTimelineCall(path.node.init)) {
timelineCount += 1;
if (!timelineVar) {
@@ -395,7 +414,7 @@ function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection {
}
this.traverse(path);
},
visitAssignmentExpression(path: any) {
visitAssignmentExpression(path: AstPath) {
if (isGsapTimelineCall(path.node.right)) {
timelineCount += 1;
if (!timelineVar) {
@@ -413,20 +432,20 @@ function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection {
// ── Find All Tween Calls ────────────────────────────────────────────────────
interface TweenCallInfo {
path: any;
node: any;
path: AstPath;
node: AstNode;
method: GsapMethod;
selector: string;
varsArg: any;
fromArg?: any;
positionArg?: any;
varsArg: AstNode;
fromArg?: AstNode;
positionArg?: AstNode;
}
/**
* True when the member chain of `callNode.callee` is rooted at the timeline
* variable `tl.to(...)` and every link of a chain `tl.to(...).to(...)`.
*/
function isTimelineRootedCall(callNode: any, timelineVar: string): boolean {
function isTimelineRootedCall(callNode: AstNode, timelineVar: string): boolean {
let obj = callNode.callee?.object;
while (obj?.type === "CallExpression") {
obj = obj.callee?.object;
@@ -435,14 +454,14 @@ function isTimelineRootedCall(callNode: any, timelineVar: string): boolean {
}
function findAllTweenCalls(
ast: any,
ast: AstNode,
timelineVar: string,
scope: ScopeBindings,
targetBindings: TargetBindings,
): TweenCallInfo[] {
const results: TweenCallInfo[] = [];
recast.types.visit(ast, {
visitCallExpression(path: any) {
visitCallExpression(path: AstPath) {
const node = path.node;
const callee = node.callee;
if (
@@ -511,13 +530,13 @@ const EXTRAS_KEYS = new Set([
* Extract raw source text for a property in an ObjectExpression AST node.
* Returns the printed source of the value node, suitable for verbatim re-emission.
*/
function extractRawPropertySource(varsArgNode: any, key: string): string | undefined {
function extractRawPropertySource(varsArgNode: AstNode, key: string): string | undefined {
const node = findPropertyNode(varsArgNode, key);
return node ? recast.print(node).code : undefined;
}
/** Find the raw AST node for a named property inside an ObjectExpression. */
function findPropertyNode(varsArgNode: any, key: string): any | undefined {
function findPropertyNode(varsArgNode: AstNode, key: string): AstNode | undefined {
if (varsArgNode?.type !== "ObjectExpression") return undefined;
for (const prop of varsArgNode.properties ?? []) {
if (!isObjectProperty(prop)) continue;
@@ -531,7 +550,7 @@ function findPropertyNode(varsArgNode: any, key: string): any | undefined {
const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/;
/** Extract a string-valued ease or easeEach from an AST property node. */
function tryResolveStringProp(propValue: any, scope: ScopeBindings): string | undefined {
function tryResolveStringProp(propValue: AstNode, scope: ScopeBindings): string | undefined {
const val = resolveNode(propValue, scope);
return typeof val === "string" ? val : undefined;
}
@@ -542,7 +561,10 @@ function tryResolveStringProp(propValue: any, scope: ScopeBindings): string | un
* percentage objects, object arrays, and simple (property-array) objects.
*/
// fallow-ignore-next-line complexity
function parseKeyframesNode(node: any, scope: ScopeBindings): GsapKeyframesData | undefined {
function parseKeyframesNode(
node: AstNode | undefined,
scope: ScopeBindings,
): GsapKeyframesData | undefined {
if (!node) return undefined;
// ── Object array format: keyframes: [ { x: 0, duration: 0.5 }, ... ] ──
@@ -576,7 +598,7 @@ function parseKeyframesNode(node: any, scope: ScopeBindings): GsapKeyframesData
}
// fallow-ignore-next-line complexity
function parsePercentageKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
function parsePercentageKeyframes(node: AstNode, scope: ScopeBindings): GsapKeyframesData {
const keyframes: GsapPercentageKeyframe[] = [];
let ease: string | undefined;
let easeEach: string | undefined;
@@ -617,9 +639,12 @@ function parsePercentageKeyframes(node: any, scope: ScopeBindings): GsapKeyframe
};
}
function computeKeyframesTotalDuration(varsNode: any, scope: ScopeBindings): number | undefined {
function computeKeyframesTotalDuration(
varsNode: AstNode,
scope: ScopeBindings,
): number | undefined {
const kfNode = (varsNode.properties ?? []).find(
(p: any) => (p.key?.name ?? p.key?.value) === "keyframes",
(p: AstNode) => (p.key?.name ?? p.key?.value) === "keyframes",
)?.value;
if (!kfNode || kfNode.type !== "ArrayExpression") return undefined;
let total = 0;
@@ -632,7 +657,7 @@ function computeKeyframesTotalDuration(varsNode: any, scope: ScopeBindings): num
}
// fallow-ignore-next-line complexity
function parseObjectArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
function parseObjectArrayKeyframes(node: AstNode, scope: ScopeBindings): GsapKeyframesData {
const elements = node.elements ?? [];
const raw: Array<{
properties: Record<string, number | string>;
@@ -693,7 +718,7 @@ function parseObjectArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyfram
}
// fallow-ignore-next-line complexity
function parseSimpleArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
function parseSimpleArrayKeyframes(node: AstNode, scope: ScopeBindings): GsapKeyframesData {
const arrayProps: Map<string, (number | string)[]> = new Map();
let ease: string | undefined;
let easeEach: string | undefined;
@@ -747,10 +772,13 @@ interface MotionPathParseResult {
waypoints: Array<{ x: number; y: number }>;
}
function parseMotionPathNode(node: any, scope: ScopeBindings): MotionPathParseResult | undefined {
function parseMotionPathNode(
node: AstNode | undefined,
scope: ScopeBindings,
): MotionPathParseResult | undefined {
if (!node) return undefined;
let pathNode: any;
let pathNode: AstNode | undefined;
let autoRotate: boolean | number = false;
let curviness = 1;
let isCubic = false;
@@ -910,7 +938,7 @@ function tweenCallToAnimation(
}
const hasPositionArg = !!call.positionArg;
const posVal = hasPositionArg ? extractLiteralValue(call.positionArg, scope) : 0;
const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0;
const position: number | string =
typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0;
let duration = typeof vars.duration === "number" ? vars.duration : undefined;
@@ -1056,7 +1084,7 @@ function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
// ── Shared parse (AST + located tween calls) ────────────────────────────────
interface ParsedGsapAst {
ast: any;
ast: AstNode;
scope: ScopeBindings;
timelineVar: string;
detection: TimelineDetection;
@@ -1132,32 +1160,21 @@ export function parseGsapScript(script: string): ParsedGsap {
// in real compositions (variable targets, interleaved `gsap.set`, IIFE wrapper)
// without regenerating — and discarding — the surrounding code.
/** Render a model value to the JS source it should emit as. Mirrors gsapSerialize. */
function valueToCode(value: number | string): string {
if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6);
if (typeof value === "string") return JSON.stringify(value);
return String(value);
}
function safeKey(key: string): string {
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
}
/**
* Parse a value/expression snippet into a standalone AST expression node.
* Uses an assignment (`__hf__ = <code>`) rather than wrapping in parens so an
* object literal parses as an expression without recast re-emitting the
* surrounding parentheses.
*/
function parseExpr(code: string): any {
function parseExpr(code: string): AstNode {
return parseScript(`__hf__ = ${code};`).program.body[0].expression.right;
}
function propKeyName(prop: any): string | undefined {
function propKeyName(prop: AstNode): string | undefined {
return prop?.key?.name ?? prop?.key?.value;
}
function isObjectProperty(prop: any): boolean {
function isObjectProperty(prop: AstNode): boolean {
return prop?.type === "ObjectProperty" || prop?.type === "Property";
}
@@ -1166,16 +1183,16 @@ function isEditablePropertyKey(key: string): boolean {
return !BUILTIN_VAR_KEYS.has(key) && !DROPPED_VAR_KEYS.has(key) && !EXTRAS_KEYS.has(key);
}
function makeObjectProperty(key: string, value: number | string): any {
function makeObjectProperty(key: string, value: number | string): AstNode {
const obj = parseExpr(`{ ${safeKey(key)}: ${valueToCode(value)} }`);
return obj.properties[0];
}
/** Set (or insert) a single key on an ObjectExpression, preserving sibling keys. */
function setVarsKey(varsArg: any, key: string, value: number | string): void {
function setVarsKey(varsArg: AstNode, key: string, value: number | string): void {
if (varsArg?.type !== "ObjectExpression") return;
const existing = varsArg.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === key,
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === key,
);
if (existing) {
existing.value = parseExpr(valueToCode(value));
@@ -1188,9 +1205,9 @@ function setVarsKey(varsArg: any, key: string, value: number | string): void {
* Filter an ObjectExpression's properties, keeping non-editable keys
* and delegating the keep/drop decision for editable keys to `shouldKeep`.
*/
function filterEditableKeys(varsArg: any, shouldKeep: (key: string) => boolean): void {
function filterEditableKeys(varsArg: AstNode, shouldKeep: (key: string) => boolean): void {
if (varsArg?.type !== "ObjectExpression") return;
varsArg.properties = varsArg.properties.filter((p: any) => {
varsArg.properties = varsArg.properties.filter((p: AstNode) => {
if (!isObjectProperty(p)) return true;
const key = propKeyName(p);
if (typeof key !== "string") return true;
@@ -1205,7 +1222,7 @@ function filterEditableKeys(varsArg: any, shouldKeep: (key: string) => boolean):
* untouched.
*/
function reconcileEditableProperties(
varsArg: any,
varsArg: AstNode,
newProps: Record<string, number | string>,
): void {
filterEditableKeys(varsArg, (key) => key in newProps);
@@ -1215,21 +1232,23 @@ function reconcileEditableProperties(
}
}
function applyEaseUpdate(varsArg: AstNode, ease: string): void {
const kfNode = findKeyframesObjectNode(varsArg);
if (kfNode) {
setVarsKey(kfNode, "easeEach", ease);
removeVarsKey(varsArg, "ease");
} else {
setVarsKey(varsArg, "ease", ease);
}
}
function applyUpdatesToCall(call: TweenCallInfo, updates: Partial<GsapAnimation>): void {
if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties);
if (updates.fromProperties && call.method === "fromTo") {
if (updates.fromProperties && call.method === "fromTo" && call.fromArg) {
reconcileEditableProperties(call.fromArg, updates.fromProperties);
}
if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration);
if (updates.ease !== undefined) {
const kfNode = findKeyframesObjectNode(call.varsArg);
if (kfNode) {
setVarsKey(kfNode, "easeEach", updates.ease);
removeVarsKey(call.varsArg, "ease");
} else {
setVarsKey(call.varsArg, "ease", updates.ease);
}
}
if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease);
if (updates.position !== undefined) {
const posIdx = call.method === "fromTo" ? 3 : 2;
call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position));
@@ -1237,7 +1256,7 @@ function applyUpdatesToCall(call: TweenCallInfo, updates: Partial<GsapAnimation>
}
/** Walk up to the enclosing ExpressionStatement path (for prune / insertAfter). */
function findStatementPath(path: any): any {
function findStatementPath(path: AstPath): AstPath | null {
let p = path;
while (p) {
if (p.node?.type === "ExpressionStatement") return p;
@@ -1246,6 +1265,18 @@ function findStatementPath(path: any): any {
return null;
}
function insertAfterAnchor(parsed: ParsedGsapAst, newStatement: AstNode): void {
const lastCall = parsed.located[parsed.located.length - 1]?.call;
const anchorPath = lastCall
? findStatementPath(lastCall.path)
: findTimelineDeclarationPath(parsed.ast, parsed.timelineVar);
if (anchorPath) {
anchorPath.insertAfter(newStatement);
} else {
parsed.ast.program.body.push(newStatement);
}
}
/** Build the source for a single `tl.method(selector, vars, position)` call. */
function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation, "id">): string {
const selector = JSON.stringify(anim.targetSelector);
@@ -1328,17 +1359,7 @@ export function addAnimationToScript(
const id = `anim-${Date.now()}`;
const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
const newStatement = parseScript(statementCode).program.body[0];
const lastCall = parsed.located[parsed.located.length - 1]?.call;
const anchorPath = lastCall
? findStatementPath(lastCall.path)
: findTimelineDeclarationPath(parsed.ast, parsed.timelineVar);
if (anchorPath) {
anchorPath.insertAfter(newStatement);
} else {
parsed.ast.program.body.push(newStatement);
}
insertAfterAnchor(parsed, newStatement);
return { script: recast.print(parsed.ast).code, id };
}
@@ -1367,31 +1388,14 @@ export function addAnimationWithKeyframesToScript(
}
const selector = JSON.stringify(targetSelector);
const kfEntries = keyframes.map((kf) => {
const propEntries = Object.entries(kf.properties).map(
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
);
if (kf.ease) propEntries.push(`ease: ${JSON.stringify(kf.ease)}`);
if (kf.auto) propEntries.push(`_auto: 1`);
return `${JSON.stringify(`${kf.percentage}%`)}: { ${propEntries.join(", ")} }`;
});
const kfCode = `{ ${kfEntries.join(", ")} }`;
const kfCode = buildKeyframeObjectCode(keyframes);
const varEntries = [`keyframes: ${kfCode}`, `duration: ${valueToCode(duration)}`];
if (ease) varEntries.push(`ease: ${JSON.stringify(ease)}`);
const posCode = valueToCode(position);
const stmtCode = `${parsed.timelineVar}.to(${selector}, { ${varEntries.join(", ")} }, ${posCode});`;
const newStatement = parseScript(stmtCode).program.body[0];
const lastCall = parsed.located[parsed.located.length - 1]?.call;
const anchorPath = lastCall
? findStatementPath(lastCall.path)
: findTimelineDeclarationPath(parsed.ast, parsed.timelineVar);
if (anchorPath) {
anchorPath.insertAfter(newStatement);
} else {
parsed.ast.program.body.push(newStatement);
}
insertAfterAnchor(parsed, newStatement);
const result = recast.print(parsed.ast).code;
const reParsed = parseGsapAst(result);
@@ -1400,10 +1404,10 @@ export function addAnimationWithKeyframesToScript(
}
/** Find the statement path of `const <timelineVar> = gsap.timeline(...)`. */
function findTimelineDeclarationPath(ast: any, timelineVar: string): any {
let found: any = null;
function findTimelineDeclarationPath(ast: AstNode, timelineVar: string): AstPath | null {
let found: AstPath | null = null;
recast.types.visit(ast, {
visitVariableDeclaration(path: any) {
visitVariableDeclaration(path: AstPath) {
if (found) return false;
for (const decl of path.node.declarations ?? []) {
if (decl.id?.name === timelineVar && isGsapTimelineCall(decl.init)) {
@@ -1418,10 +1422,10 @@ function findTimelineDeclarationPath(ast: any, timelineVar: string): any {
}
/** Find the call that chains off `targetNode` (i.e. whose callee object IS it). */
function findChainParentCall(stmtNode: any, targetNode: any): any {
let found: any = null;
function findChainParentCall(stmtNode: AstNode, targetNode: AstNode): AstNode | null {
let found: AstNode | null = null;
recast.types.visit(stmtNode, {
visitCallExpression(p: any) {
visitCallExpression(p: AstPath) {
if (found) return false;
if (p.node.callee?.type === "MemberExpression" && p.node.callee.object === targetNode) {
found = p.node;
@@ -1645,11 +1649,30 @@ function keyframePropsToCode(kf: { properties: Record<string, number | string> }
return Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
}
function buildKeyframeObjectCode(
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
}>,
options?: { easeEach?: string },
): string {
const entries = keyframes.map((kf) => {
const props = keyframePropsToCode(kf);
if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`);
if (kf.auto) props.push(`_auto: 1`);
return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`;
});
if (options?.easeEach) entries.push(`easeEach: ${JSON.stringify(options.easeEach)}`);
return `{ ${entries.join(", ")} }`;
}
/** Remove a named property from an ObjectExpression's properties array. */
function removeVarsKey(varsArg: any, key: string): void {
function removeVarsKey(varsArg: AstNode, key: string): void {
if (varsArg?.type !== "ObjectExpression") return;
varsArg.properties = varsArg.properties.filter(
(p: any) => !(isObjectProperty(p) && propKeyName(p) === key),
(p: AstNode) => !(isObjectProperty(p) && propKeyName(p) === key),
);
}
@@ -1661,7 +1684,10 @@ function percentageFromKey(key: string): number {
const PCT_TOLERANCE = 2;
function findKeyframePropByPct(kfNode: any, percentage: number): { idx: number; prop: any } | null {
function findKeyframePropByPct(
kfNode: AstNode,
percentage: number,
): { idx: number; prop: AstNode } | null {
const props = kfNode.properties;
for (let i = 0; i < props.length; i++) {
if (!isObjectProperty(props[i])) continue;
@@ -1675,7 +1701,10 @@ function findKeyframePropByPct(kfNode: any, percentage: number): { idx: number;
}
/** Build a keyframe value AST node from properties and optional ease. */
function buildKeyframeValueNode(properties: Record<string, number | string>, ease?: string): any {
function buildKeyframeValueNode(
properties: Record<string, number | string>,
ease?: string,
): AstNode {
const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
if (ease) entries.push(`ease: ${JSON.stringify(ease)}`);
return parseExpr(`{ ${entries.join(", ")} }`);
@@ -1696,15 +1725,26 @@ function locateAnimation(
return target ? { parsed, target } : null;
}
function locateAnimationWithFallback(
script: string,
animationId: string,
): ReturnType<typeof locateAnimation> {
const loc = locateAnimation(script, animationId);
if (loc) return loc;
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
if (convertedId === animationId) return null;
return locateAnimation(script, convertedId);
}
/** Find the keyframes ObjectExpression node on a tween's varsArg, or null. */
function findKeyframesObjectNode(varsArg: any): any | null {
function findKeyframesObjectNode(varsArg: AstNode): AstNode | null {
const node = findPropertyNode(varsArg, "keyframes");
return node?.type === "ObjectExpression" ? node : null;
}
/** Filter percentage-keyed properties from a keyframes ObjectExpression. */
function filterPercentageProps(kfNode: any): any[] {
return kfNode.properties.filter((p: any) => {
function filterPercentageProps(kfNode: AstNode): AstNode[] {
return kfNode.properties.filter((p: AstNode) => {
if (!isObjectProperty(p)) return false;
const key = propKeyName(p);
return typeof key === "string" && PERCENTAGE_KEY_RE.test(key);
@@ -1716,7 +1756,7 @@ function filterPercentageProps(kfNode: any): any[] {
* then remove `keyframes` and `easeEach` from varsArg. Skips the `ease` key
* from the record (per-keyframe ease, not a tween ease).
*/
function collapseKeyframesToFlat(varsArg: any, record: Record<string, unknown>): void {
function collapseKeyframesToFlat(varsArg: AstNode, record: Record<string, unknown>): void {
for (const [k, v] of Object.entries(record)) {
if (k === "ease") continue;
if (typeof v === "number" || typeof v === "string") setVarsKey(varsArg, k, v);
@@ -1731,11 +1771,7 @@ function collapseKeyframesToFlat(varsArg: any, record: Record<string, unknown>):
* updateKeyframeInScript.
*/
function locateKeyframeCtx(script: string, animationId: string, percentage: number) {
let loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
const loc = locateAnimationWithFallback(script, animationId);
if (!loc) return null;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return null;
@@ -1754,21 +1790,13 @@ export function addKeyframeToScript(
ease?: string,
backfillDefaults?: Record<string, number | string>,
): string {
let loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
let loc = locateAnimationWithFallback(script, animationId);
if (!loc) return script;
let kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) {
script = convertToKeyframesInScript(script, animationId);
loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
loc = locateAnimationWithFallback(script, animationId);
if (!loc) return script;
kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
@@ -1818,7 +1846,7 @@ export function addKeyframeToScript(
if (percentage > 0 && percentage < 100) {
const pctProps = filterPercentageProps(kfNode);
const allPcts = pctProps
.map((p: any) => percentageFromKey(propKeyName(p) ?? ""))
.map((p: AstNode) => percentageFromKey(propKeyName(p) ?? ""))
.filter((n: number) => !Number.isNaN(n) && n !== percentage)
.sort((a: number, b: number) => a - b);
const leftNeighbor = allPcts.filter((p: number) => p < percentage).pop();
@@ -1826,10 +1854,12 @@ export function addKeyframeToScript(
for (const endPct of [0, 100]) {
const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100;
if (!isNeighbor) continue;
const endProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === endPct);
const endProp = pctProps.find(
(p: AstNode) => percentageFromKey(propKeyName(p) ?? "") === endPct,
);
if (!endProp?.value || endProp.value.type !== "ObjectExpression") continue;
const hasAuto = endProp.value.properties.some(
(p: any) => isObjectProperty(p) && propKeyName(p) === "_auto",
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "_auto",
);
if (!hasAuto) continue;
const updatedProps = { ...properties, _auto: 1 as number | string };
@@ -1848,7 +1878,9 @@ export function addKeyframeToScript(
const valObj = prop.value;
if (!valObj || valObj.type !== "ObjectExpression") continue;
const existingKeys = new Set(
valObj.properties.filter((p: any) => isObjectProperty(p)).map((p: any) => propKeyName(p)),
valObj.properties
.filter((p: AstNode) => isObjectProperty(p))
.map((p: AstNode) => propKeyName(p)),
);
for (const pk of newPropKeys) {
if (existingKeys.has(pk)) continue;
@@ -1887,7 +1919,7 @@ export function removeKeyframeFromScript(
if (remainingKfs.length < 2) {
const record =
remainingKfs.length === 1
? objectExpressionToRecord(remainingKfs[0].value, loc.parsed.scope)
? objectExpressionToRecord(remainingKfs[0]!.value, loc.parsed.scope)
: {};
collapseKeyframesToFlat(loc.target.call.varsArg, record);
}
@@ -1973,11 +2005,11 @@ function resolveConversionProps(
}
/** Strip editable properties and ease/keyframes keys from a varsArg. */
function stripEditableAndEase(varsArg: any): void {
function stripEditableAndEase(varsArg: AstNode): void {
// ease is a BUILTIN_VAR_KEY (not editable), so filterEditableKeys won't remove it —
// drop it explicitly before filtering, along with keyframes.
if (varsArg?.type !== "ObjectExpression") return;
varsArg.properties = varsArg.properties.filter((p: any) => {
varsArg.properties = varsArg.properties.filter((p: AstNode) => {
if (!isObjectProperty(p)) return true;
const key = propKeyName(p);
return key !== "ease" && key !== "keyframes";
@@ -1987,7 +2019,7 @@ function stripEditableAndEase(varsArg: any): void {
/** Build and prepend a keyframes property node onto varsArg. */
function insertKeyframesProp(
varsArg: any,
varsArg: AstNode,
fromProps: Record<string, number | string>,
toProps: Record<string, number | string>,
easeEach?: string,
@@ -2011,11 +2043,7 @@ export function convertToKeyframesInScript(
animationId: string,
resolvedFromValues?: Record<string, number | string>,
): string {
let loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
let loc = locateAnimationWithFallback(script, animationId);
if (!loc) return script;
const anim = loc.target.animation;
@@ -2046,17 +2074,13 @@ export function convertToKeyframesInScript(
* last keyframe's properties.
*/
export function removeAllKeyframesFromScript(script: string, animationId: string): string {
let loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
let loc = locateAnimationWithFallback(script, animationId);
if (!loc) return script;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
const kfEntries = filterPercentageProps(kfNode)
.map((p: any) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
.map((p: AstNode) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
.filter((e) => !Number.isNaN(e.pct))
.sort((a, b) => a.pct - b.pct);
if (kfEntries.length === 0) return script;
@@ -2086,11 +2110,7 @@ export function materializeKeyframesInScript(
easeEach?: string,
resolvedSelector?: string,
): string {
let loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
let loc = locateAnimationWithFallback(script, animationId);
if (!loc) return script;
const varsArg = loc.target.call.varsArg;
@@ -2100,19 +2120,9 @@ export function materializeKeyframesInScript(
loc.target.call.node.arguments[0] = parseExpr(JSON.stringify(resolvedSelector));
}
const entries: string[] = [];
for (const kf of sortedKeyframes(keyframes)) {
const propEntries = keyframePropsToCode(kf);
if (kf.ease) propEntries.push(`ease: ${JSON.stringify(kf.ease)}`);
entries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`);
}
if (easeEach) {
entries.push(`easeEach: ${JSON.stringify(easeEach)}`);
}
const kfObjCode = `{ ${entries.join(", ")} }`;
const kfObjCode = buildKeyframeObjectCode(sortedKeyframes(keyframes), { easeEach });
const kfParent = varsArg.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === "keyframes",
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "keyframes",
);
if (kfParent) {
kfParent.value = parseExpr(kfObjCode);
@@ -2128,6 +2138,25 @@ export function materializeKeyframesInScript(
// ── Arc Path (motionPath) AST Mutations ──────────────────────────────────
function numericXY(props: Record<string, number | string>): { x: number; y: number } | null {
const x = props.x;
const y = props.y;
return typeof x === "number" && typeof y === "number" ? { x, y } : null;
}
function extractArcWaypoints(anim: GsapAnimation): Array<{ x: number; y: number }> {
const kfs = anim.keyframes?.keyframes ?? [];
const waypoints = kfs.map((kf) => numericXY(kf.properties)).filter((p) => p !== null);
if (waypoints.length >= 2) return waypoints;
const px = anim.properties.x;
const py = anim.properties.y;
if (typeof px !== "number" && typeof py !== "number") return waypoints;
return [
{ x: 0, y: 0 },
{ x: typeof px === "number" ? px : 0, y: typeof py === "number" ? py : 0 },
];
}
function buildMotionPathObjectCode(config: {
waypoints: Array<{ x: number; y: number }>;
segments: ArcPathSegment[];
@@ -2192,14 +2221,14 @@ export function setArcPathInScript(
if (!config.enabled) {
// Disable arc: restore x/y from motionPath's last waypoint, then remove motionPath
const motionPathProp = varsArg.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === "motionPath",
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath",
);
if (motionPathProp) {
const mpVal = motionPathProp.value;
let pathArr: any[] | undefined;
let pathArr: AstNode[] | undefined;
if (mpVal?.type === "ObjectExpression") {
const pathProp = mpVal.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === "path",
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "path",
);
if (pathProp?.value?.type === "ArrayExpression") pathArr = pathProp.value.elements;
}
@@ -2220,26 +2249,7 @@ export function setArcPathInScript(
return recast.print(loc.parsed.ast).code;
}
// Extract x/y waypoints from keyframes or flat tween properties
const kfs = anim.keyframes?.keyframes ?? [];
const waypoints: Array<{ x: number; y: number }> = [];
for (const kf of kfs) {
const x = typeof kf.properties.x === "number" ? kf.properties.x : undefined;
const y = typeof kf.properties.y === "number" ? kf.properties.y : undefined;
if (x !== undefined && y !== undefined) waypoints.push({ x, y });
}
// For flat tweens with x/y in properties, synthesize start → end waypoints
if (waypoints.length < 2) {
const px = anim.properties.x;
const py = anim.properties.y;
if (typeof px === "number" || typeof py === "number") {
waypoints.length = 0;
waypoints.push({ x: 0, y: 0 });
waypoints.push({ x: typeof px === "number" ? px : 0, y: typeof py === "number" ? py : 0 });
}
}
const waypoints = extractArcWaypoints(anim);
if (waypoints.length < 2) return script;
// Build segments — use provided segments or create defaults
@@ -2257,7 +2267,7 @@ export function setArcPathInScript(
// Set motionPath on the vars
const motionPathNode = parseExpr(motionPathCode);
const existingProp = varsArg.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === "motionPath",
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath",
);
if (existingProp) {
existingProp.value = motionPathNode;
@@ -2271,7 +2281,7 @@ export function setArcPathInScript(
if (kfNode) {
for (const pctProp of filterPercentageProps(kfNode)) {
if (pctProp.value?.type === "ObjectExpression") {
pctProp.value.properties = pctProp.value.properties.filter((p: any) => {
pctProp.value.properties = pctProp.value.properties.filter((p: AstNode) => {
const k = propKeyName(p);
return k !== "x" && k !== "y";
});
@@ -2303,15 +2313,7 @@ export function updateArcSegmentInScript(
segments[segmentIndex] = { ...segments[segmentIndex]!, ...update };
// Rebuild the full motionPath with updated segments
const kfs = anim.keyframes?.keyframes ?? [];
const waypoints: Array<{ x: number; y: number }> = [];
for (const kf of kfs) {
const x = typeof kf.properties.x === "number" ? kf.properties.x : undefined;
const y = typeof kf.properties.y === "number" ? kf.properties.y : undefined;
if (x !== undefined && y !== undefined) waypoints.push({ x, y });
}
const waypoints = extractArcWaypoints(anim);
if (waypoints.length < 2) return script;
const motionPathCode = buildMotionPathObjectCode({
@@ -2322,7 +2324,7 @@ export function updateArcSegmentInScript(
const varsArg = loc.target.call.varsArg;
const existingProp = varsArg.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === "motionPath",
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath",
);
if (existingProp) {
existingProp.value = parseExpr(motionPathCode);
@@ -2353,11 +2355,7 @@ export function splitIntoPropertyGroups(
script: string,
animationId: string,
): { script: string; ids: string[] } {
let loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
let loc = locateAnimationWithFallback(script, animationId);
if (!loc) return { script, ids: [animationId] };
const anim = loc.target.animation;
@@ -2523,7 +2521,7 @@ export function unrollDynamicAnimations(
: "0";
// Find the enclosing loop (for/forEach) by walking up the AST path
let loopNode: any = null;
let loopNode: AstNode | null = null;
let current = loc.target.call.path;
while (current) {
const node = current.node ?? current.value;
@@ -2550,16 +2548,11 @@ export function unrollDynamicAnimations(
// Build replacement code: individual tl.to() calls for each element
const calls: string[] = [];
for (const el of elements) {
const kfEntries: string[] = [];
for (const kf of sortedKeyframes(el.keyframes)) {
const propEntries = keyframePropsToCode(kf);
kfEntries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`);
}
if (el.easeEach) {
kfEntries.push(`easeEach: ${JSON.stringify(el.easeEach)}`);
}
const kfCode = buildKeyframeObjectCode(sortedKeyframes(el.keyframes), {
easeEach: el.easeEach,
});
calls.push(
`${loc.parsed.timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`,
`${loc.parsed.timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`,
);
}
+13 -8
View File
@@ -153,7 +153,7 @@ ${lines.join("\n")}${mediaSync}${postamble}
`;
}
function serializeValue(value: unknown): string {
export function serializeValue(value: unknown): string {
if (typeof value === "string" && value.startsWith("__raw:")) {
return value.slice(6);
}
@@ -161,10 +161,13 @@ function serializeValue(value: unknown): string {
return String(value);
}
export function safeJsKey(key: string): string {
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
}
function serializeObject(obj: Record<string, number | string>): string {
const entries = Object.entries(obj).map(([key, value]) => {
const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
return `${safeKey}: ${serializeValue(value)}`;
return `${safeJsKey(key)}: ${serializeValue(value)}`;
});
return `{ ${entries.join(", ")} }`;
}
@@ -172,8 +175,7 @@ function serializeObject(obj: Record<string, number | string>): string {
function serializeExtras(extras: Record<string, unknown>): string {
return Object.entries(extras)
.map(([key, value]) => {
const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
return `${safeKey}: ${serializeValue(value)}`;
return `${safeJsKey(key)}: ${serializeValue(value)}`;
})
.join(", ");
}
@@ -296,9 +298,12 @@ export function gsapAnimationsToKeyframes(
const baseValueEpsilon = 0.00001;
return animations
.filter((a) => validMethods.includes(a.method) && typeof a.position === "number")
.filter(
(a): a is GsapAnimation & { position: number } =>
validMethods.includes(a.method) && typeof a.position === "number",
)
.map((a) => {
const relativeTimeRaw = (a.position as number) - elementStartTime;
const relativeTimeRaw = a.position - elementStartTime;
const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw;
const properties: Partial<KeyframeProperties> = {};
@@ -331,5 +336,5 @@ export function gsapAnimationsToKeyframes(
ease: a.ease,
};
})
.filter((kf): kf is NonNullable<typeof kf> => kf !== null) as Keyframe[];
.filter((kf): kf is NonNullable<typeof kf> => kf !== null);
}
+14 -16
View File
@@ -139,19 +139,16 @@ function parseResolutionFromHtml(doc: Document): CanvasResolution | null {
return null;
}
const UHD_SQUARE_MIN = 2160;
const UHD_RECT_MIN = 3840;
function resolveResolutionFromDimensions(width: number, height: number): CanvasResolution {
const longSide = Math.max(width, height);
// UHD cutoff is the long side of the 4K presets (3840 for `landscape-4k` /
// `portrait-4k`, 2160 for `square-4k`). A looser threshold (e.g. >= 2560)
// would silently misclassify QHD/1440p (2560x1440) as 4K, which is the
// wrong default for a common authoring resolution closer to 1080p than to
// UHD. Authors who genuinely want the 4K preset can still set
// `data-resolution="..."` explicitly.
if (width === height) {
return longSide >= 2160 ? "square-4k" : "square";
return longSide >= UHD_SQUARE_MIN ? "square-4k" : "square";
}
const isLandscape = width > height;
const isUhd = longSide >= 3840;
const isUhd = longSide >= UHD_RECT_MIN;
if (isLandscape) return isUhd ? "landscape-4k" : "landscape";
return isUhd ? "portrait-4k" : "portrait";
}
@@ -612,16 +609,20 @@ export function addElementToHtml(
let newEl: Element;
function applyMediaAttrs(el: Element, mediaEl: TimelineMediaElement): void {
if (mediaEl.src) el.setAttribute("src", mediaEl.src);
if (mediaEl.volume !== undefined && mediaEl.volume !== 1) {
el.setAttribute("data-volume", String(mediaEl.volume));
}
}
switch (element.type) {
case "video": {
const mediaEl = element as TimelineMediaElement;
newEl = doc.createElement("video");
newEl.setAttribute("muted", "");
newEl.setAttribute("playsinline", "");
if (mediaEl.src) newEl.setAttribute("src", mediaEl.src);
if (mediaEl.volume !== undefined && mediaEl.volume !== 1) {
newEl.setAttribute("data-volume", String(mediaEl.volume));
}
applyMediaAttrs(newEl, mediaEl);
if (mediaEl.hasAudio) {
newEl.setAttribute("data-has-audio", "true");
}
@@ -637,10 +638,7 @@ export function addElementToHtml(
case "audio": {
const mediaEl = element as TimelineMediaElement;
newEl = doc.createElement("audio");
if (mediaEl.src) newEl.setAttribute("src", mediaEl.src);
if (mediaEl.volume !== undefined && mediaEl.volume !== 1) {
newEl.setAttribute("data-volume", String(mediaEl.volume));
}
applyMediaAttrs(newEl, mediaEl);
break;
}
case "text":
+1 -92
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { initRuntimeAnalytics, emitAnalyticsEvent, emitPerformanceMetric } from "./analytics";
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
describe("runtime analytics", () => {
let postMessage: ReturnType<typeof vi.fn>;
@@ -58,94 +58,3 @@ describe("runtime analytics", () => {
expect(postMessage).toHaveBeenCalledTimes(events.length);
});
});
describe("runtime performance metrics", () => {
let postMessage: ReturnType<typeof vi.fn>;
beforeEach(() => {
postMessage = vi.fn();
initRuntimeAnalytics(postMessage);
// Clean up DevTools marks between tests to avoid cross-test interference.
if (typeof performance !== "undefined" && typeof performance.clearMarks === "function") {
performance.clearMarks();
}
});
it("emits a perf metric via postMessage", () => {
emitPerformanceMetric("player_scrub_latency", 12.5);
expect(postMessage).toHaveBeenCalledWith({
source: "hf-preview",
type: "perf",
name: "player_scrub_latency",
value: 12.5,
tags: {},
});
});
it("passes tags through", () => {
emitPerformanceMetric("player_decoder_count", 3, {
composition_id: "abc123",
mode: "isolated",
});
expect(postMessage).toHaveBeenCalledWith({
source: "hf-preview",
type: "perf",
name: "player_decoder_count",
value: 3,
tags: { composition_id: "abc123", mode: "isolated" },
});
});
it("normalizes missing tags to an empty object", () => {
emitPerformanceMetric("player_playback_fps", 60);
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ tags: {} }));
});
it("supports zero and negative values", () => {
emitPerformanceMetric("player_dropped_frames", 0);
emitPerformanceMetric("player_media_sync_drift", -8.3);
expect(postMessage).toHaveBeenNthCalledWith(1, expect.objectContaining({ value: 0 }));
expect(postMessage).toHaveBeenNthCalledWith(2, expect.objectContaining({ value: -8.3 }));
});
it("does not throw when postMessage is not set", () => {
initRuntimeAnalytics(null as unknown as (payload: unknown) => void);
expect(() => emitPerformanceMetric("player_load_time", 250)).not.toThrow();
});
it("does not throw when postMessage throws", () => {
postMessage.mockImplementation(() => {
throw new Error("channel closed");
});
expect(() => emitPerformanceMetric("player_scrub_latency", 12)).not.toThrow();
});
it("does not throw when performance.mark throws", () => {
const original = performance.mark;
// Vitest provides a real performance API; replace mark with a thrower for this test.
performance.mark = vi.fn(() => {
throw new Error("mark failed");
}) as typeof performance.mark;
try {
expect(() => emitPerformanceMetric("player_load_time", 100)).not.toThrow();
// Even though performance.mark threw, the bridge should still receive the metric.
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: "perf", name: "player_load_time", value: 100 }),
);
} finally {
performance.mark = original;
}
});
it("writes a User Timing mark with detail for DevTools visibility", () => {
if (typeof performance.getEntriesByName !== "function") {
// Older test environments — skip the DevTools assertion but don't fail.
return;
}
emitPerformanceMetric("player_composition_switch", 42, { from: "a", to: "b" });
const entries = performance.getEntriesByName("player_composition_switch", "mark");
expect(entries.length).toBeGreaterThan(0);
const mark = entries[entries.length - 1] as PerformanceMark;
expect(mark.detail).toEqual({ value: 42, tags: { from: "a", to: "b" } });
});
});
+3 -83
View File
@@ -1,33 +1,9 @@
import { swallow } from "./diagnostics";
/**
* Runtime analytics & performance telemetry vendor-agnostic event emission.
* Runtime analytics vendor-agnostic event emission via postMessage.
*
* The runtime emits structured events via postMessage. The host application
* decides what to do with them: forward to PostHog, Mixpanel, Amplitude,
* a custom logger, or nothing at all.
*
* For session replay: initialize your analytics SDK (e.g. PostHog) only in
* the parent app with `recordCrossOriginIframes: true`. No SDK needs to run
* inside this iframe.
*
* ## Host app integration
*
* ```javascript
* window.addEventListener("message", (e) => {
* if (e.data?.source !== "hf-preview") return;
*
* if (e.data.type === "analytics") {
* // discrete lifecycle events: composition_loaded, played, seeked, etc.
* posthog.capture(e.data.event, e.data.properties);
* }
*
* if (e.data.type === "perf") {
* // numeric performance metrics: scrub latency, fps, decoder count, etc.
* // Aggregate per-session (p50/p95) and forward on flush.
* myMetrics.observe(e.data.name, e.data.value, e.data.tags);
* }
* });
* ```
* The host application decides what to do with events: forward to PostHog,
* Mixpanel, Amplitude, a custom logger, or nothing at all.
*/
export type RuntimeAnalyticsEvent =
@@ -40,16 +16,7 @@ export type RuntimeAnalyticsEvent =
export type RuntimeAnalyticsProperties = Record<string, string | number | boolean | null>;
/**
* Tags attached to a performance metric small, low-cardinality identifiers
* (composition id hash, media count bucket, browser version, etc.). Same shape
* as analytics properties so hosts can forward both through one pipeline.
*/
export type RuntimePerformanceTags = Record<string, string | number | boolean | null>;
// Stored reference to the postRuntimeMessage function, set during init.
// Avoids a circular import between analytics ↔ bridge. Shared by both
// emitAnalyticsEvent and emitPerformanceMetric — one bridge, two channels.
let _postMessage: ((payload: unknown) => void) | null = null;
/**
@@ -81,50 +48,3 @@ export function emitAnalyticsEvent(
swallow("runtime.analytics.site1", err);
}
}
/**
* Emit a numeric performance metric through the bridge.
*
* Used for player-perf telemetry scrub latency, sustained fps, dropped
* frames, decoder count, composition load time, media sync drift. The host
* aggregates per-session values (p50/p95) and forwards to its observability
* pipeline on flush.
*
* Also writes a `performance.mark()` so the metric shows up under the
* DevTools Performance panel's "User Timing" track for local debugging,
* with `value` and `tags` available on the entry's `detail` field.
*
* @param name Metric name, e.g. "player_scrub_latency", "player_playback_fps"
* @param value Numeric value (units are metric-specific: ms for latency, fps for rate, etc.)
* @param tags Optional low-cardinality tags (composition id, media count bucket, etc.)
*/
export function emitPerformanceMetric(
name: string,
value: number,
tags?: RuntimePerformanceTags,
): void {
// Local DevTools breadcrumb. Wrapped because performance.mark() can throw on
// strict CSP, when the document is not yet ready, or when `detail` is non-cloneable.
try {
if (typeof performance !== "undefined" && typeof performance.mark === "function") {
performance.mark(name, { detail: { value, tags: tags ?? {} } });
}
} catch (err) {
// performance API unavailable or rejected — keep going
swallow("runtime.analytics.site2", err);
}
if (!_postMessage) return;
try {
_postMessage({
source: "hf-preview",
type: "perf",
name,
value,
tags: tags ?? {},
});
} catch (err) {
// Never let telemetry failures affect the runtime
swallow("runtime.analytics.site3", err);
}
}
+2 -15
View File
@@ -27,24 +27,11 @@
* helper call is a real statement, so no `no-empty` warnings ship in the
* inlined IIFE.
*/
export interface SwallowedEvent {
/** Short, descriptive label naming the operation that failed. */
label: string;
/** The thrown value (often an Error, but JS allows anything). */
error: unknown;
}
interface HFDebugSurface {
__hfDebug?: boolean;
__HYPERFRAMES_DEBUG?: boolean;
__hf?: {
onSwallowed?: (event: SwallowedEvent) => void;
};
}
import { getDebugSurface } from "./globals.js";
export function swallow(label: string, error?: unknown): void {
if (typeof window === "undefined") return;
const w = window as unknown as HFDebugSurface;
const w = getDebugSurface();
const handler = w.__hf?.onSwallowed;
if (handler) {
+11
View File
@@ -0,0 +1,11 @@
export interface HFDebugSurface {
__hfDebug?: boolean;
__HYPERFRAMES_DEBUG?: boolean;
__hf?: {
onSwallowed?: (event: { label: string; error: unknown }) => void;
};
}
export function getDebugSurface(): HFDebugSurface {
return globalThis as HFDebugSurface;
}
@@ -1,248 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { createMediaPreloadManager } from "./mediaPreloader";
function mockMediaElement(attrs: {
start: string;
duration?: string;
tag?: string;
}): HTMLMediaElement {
const el = {
tagName: (attrs.tag ?? "VIDEO").toUpperCase(),
preload: "auto",
readyState: 0,
duration: Number.NaN,
defaultPlaybackRate: 1,
loop: false,
src: `blob:mock-${attrs.start}`,
dataset: {
start: attrs.start,
duration: attrs.duration,
},
hasAttribute: (name: string) => name === "data-start",
getAttribute: (name: string) => {
if (name === "data-start") return attrs.start;
if (name === "data-duration") return attrs.duration ?? null;
return null;
},
removeAttribute: (name: string) => {
if (name === "src") {
(el as Record<string, unknown>).src = "";
}
},
closest: () => null,
load: vi.fn(),
} as unknown as HTMLMediaElement;
return el;
}
function setupDOM(elements: HTMLMediaElement[]): void {
const originalQuerySelector = document.querySelectorAll.bind(document);
document.querySelectorAll = ((selector: string) => {
if (selector === "video, audio") return elements as unknown as NodeListOf<Element>;
return originalQuerySelector(selector);
}) as typeof document.querySelectorAll;
}
function createTestFixture(
count: number,
options?: Parameters<typeof createMediaPreloadManager>[0],
) {
const elements = Array.from({ length: count }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
const manager = createMediaPreloadManager(options);
manager.refresh();
return { elements, manager };
}
describe("createMediaPreloadManager", () => {
let elements: HTMLMediaElement[];
beforeEach(() => {
elements = [];
});
it("is not lazy when fewer than 3 media elements", () => {
elements = [
mockMediaElement({ start: "0", duration: "5" }),
mockMediaElement({ start: "5", duration: "5" }),
];
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
expect(manager.isLazy()).toBe(false);
});
it("activates lazy mode at exactly LAZY_THRESHOLD (3 elements)", () => {
const { manager } = createTestFixture(3);
expect(manager.isLazy()).toBe(true);
});
it("is not lazy with 2 elements (below threshold)", () => {
const { manager } = createTestFixture(2);
expect(manager.isLazy()).toBe(false);
});
it("activates lazy mode with 8 media elements", () => {
const { manager } = createTestFixture(8);
expect(manager.isLazy()).toBe(true);
});
it("activates lazy mode for 4-5 clip compositions without spurious eviction", () => {
const f = createTestFixture(4);
expect(f.manager.isLazy()).toBe(true);
for (const el of f.elements) el.preload = "metadata";
f.manager.sync(0);
const promoted = f.elements.filter((el) => el.preload === "auto").length;
expect(promoted).toBeGreaterThanOrEqual(2);
expect(promoted).toBeLessThanOrEqual(4);
f.manager.sync(0);
const evicted = f.elements.filter(
(el) =>
el.preload === "metadata" && (el.load as ReturnType<typeof vi.fn>).mock.calls.length > 1,
);
expect(evicted.length).toBe(0);
});
it("sync promotes clips in the lookahead window", () => {
const f = createTestFixture(8);
for (const el of f.elements) el.preload = "metadata";
f.manager.sync(0);
expect(f.elements[0].preload).toBe("auto");
expect(f.elements[1].preload).toBe("auto");
expect(f.elements[7].preload).toBe("metadata");
});
it("preloadAroundTime promotes clips near seek target", () => {
const f = createTestFixture(10);
for (const el of f.elements) el.preload = "metadata";
f.manager.preloadAroundTime(30);
expect(f.elements[6].preload).toBe("auto");
expect(f.elements[7].preload).toBe("auto");
expect(f.elements[0].preload).toBe("metadata");
});
it("sync is a no-op when not lazy", () => {
const f = createTestFixture(2);
f.manager.sync(0);
expect(f.manager.isLazy()).toBe(false);
});
it("guarantees at least LOOKAHEAD_MIN_CLIPS are promoted", () => {
// Use 20s spacing so only 1 clip falls in the 10s lookahead window
elements = Array.from({ length: 8 }, (_, i) =>
mockMediaElement({ start: String(i * 20), duration: "5" }),
);
setupDOM(elements);
const manager = createMediaPreloadManager();
manager.refresh();
for (const el of elements) el.preload = "metadata";
manager.sync(0);
expect(elements.filter((el) => el.preload === "auto").length).toBeGreaterThanOrEqual(2);
});
it("evicts clips when scrubbing away from them", () => {
const f = createTestFixture(10);
for (const el of f.elements) el.preload = "metadata";
f.manager.sync(0);
expect(f.elements[0].preload).toBe("auto");
f.manager.sync(40);
expect(f.elements[0].preload).toBe("metadata");
expect(f.elements[0].src).toBe("");
expect(f.elements[8].preload).toBe("auto");
});
it("restores src when re-promoting a previously evicted clip", () => {
const f = createTestFixture(10);
for (const el of f.elements) el.preload = "metadata";
const originalSrc0 = f.elements[0].src;
f.manager.sync(0);
f.manager.sync(40);
expect(f.elements[0].src).toBe("");
f.manager.sync(0);
expect(f.elements[0].src).toBe(originalSrc0);
expect(f.elements[0].preload).toBe("auto");
});
it("does not exceed MAX_PROMOTED (5) clips", () => {
const f = createTestFixture(10);
for (const el of f.elements) el.preload = "metadata";
f.manager.sync(0);
expect(f.elements.filter((el) => el.preload === "auto").length).toBeLessThanOrEqual(5);
f.manager.sync(25);
expect(f.elements.filter((el) => el.preload === "auto").length).toBeLessThanOrEqual(5);
});
it("calls load() when evicting to release buffers", () => {
const f = createTestFixture(10);
for (const el of f.elements) el.preload = "metadata";
f.manager.sync(0);
const loadCallsBefore = (f.elements[0].load as ReturnType<typeof vi.fn>).mock.calls.length;
f.manager.sync(40);
expect((f.elements[0].load as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(
loadCallsBefore,
);
});
it("isLazy reports true with 6+ clips so caller can gate render-mode bypass", () => {
const { manager } = createTestFixture(6);
expect(manager.isLazy()).toBe(true);
});
it("calls onActivation when lazy mode activates", () => {
const onActivation = vi.fn();
createTestFixture(8, { onActivation });
expect(onActivation).toHaveBeenCalledOnce();
expect(onActivation).toHaveBeenCalledWith(8);
});
it("does not call onActivation below threshold", () => {
const onActivation = vi.fn();
createTestFixture(2, { onActivation });
expect(onActivation).not.toHaveBeenCalled();
});
it("calls onActivation only once across multiple refreshes", () => {
const onActivation = vi.fn();
const { manager } = createTestFixture(8, { onActivation });
manager.refresh();
manager.refresh();
expect(onActivation).toHaveBeenCalledOnce();
});
it("respects window.__HF_LAZY_PRELOAD_THRESHOLD override", () => {
elements = Array.from({ length: 2 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
// 2 elements is below the default threshold (3) but at our custom one
(window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD = 2;
const manager = createMediaPreloadManager();
manager.refresh();
expect(manager.isLazy()).toBe(true);
// Clean up
delete (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD;
});
it("falls back to default threshold when __HF_LAZY_PRELOAD_THRESHOLD is not set", () => {
elements = Array.from({ length: 2 }, (_, i) =>
mockMediaElement({ start: String(i * 5), duration: "5" }),
);
setupDOM(elements);
// Ensure it's not set
delete (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD;
const manager = createMediaPreloadManager();
manager.refresh();
expect(manager.isLazy()).toBe(false);
});
});
-170
View File
@@ -1,170 +0,0 @@
import { refreshRuntimeMediaCache, type RuntimeMediaClip } from "./media";
// Start lazy preload management at 3 clips to keep memory pressure low from
// the start. The previous threshold of 6 let medium compositions (45 heavy
// videos) saturate browser memory before the preloader kicked in.
const LAZY_THRESHOLD = 3;
const LOOKAHEAD_SECONDS = 10;
const LOOKBEHIND_SECONDS = 3;
const LOOKAHEAD_MIN_CLIPS = 2;
// Adaptive cap: base of 4 for small sets, clamped to 6 for larger ones.
// The window-based eviction in syncWindow() is the primary memory bound;
// this cap is defense-in-depth for compositions with many short clips
// packed into the lookahead window.
const MAX_PROMOTED_BASE = 4;
const MAX_PROMOTED_CEIL = 6;
export interface MediaPreloadManager {
refresh(): void;
sync(currentTimeSeconds: number): void;
preloadAroundTime(timeSeconds: number): void;
isLazy(): boolean;
}
export function createMediaPreloadManager(options?: {
resolveStartSeconds?: (element: Element) => number;
resolveDurationSeconds?: (element: HTMLVideoElement | HTMLAudioElement) => number | null;
shouldIncludeElement?: (element: HTMLVideoElement | HTMLAudioElement) => boolean;
onActivation?: (clipCount: number) => void;
}): MediaPreloadManager {
let clips: RuntimeMediaClip[] = [];
const promoted = new Set<HTMLMediaElement>();
/** Insertion-order queue for LRU eviction (oldest first). */
const promotionOrder: HTMLMediaElement[] = [];
/** Stashed original src so we can restore after eviction. */
const originalSrc = new Map<HTMLMediaElement, string>();
let lazy = false;
let activationEmitted = false;
function refresh(): void {
const cache = refreshRuntimeMediaCache(options);
clips = cache.mediaClips;
const configuredThreshold =
typeof (window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD === "number"
? ((window as Record<string, unknown>).__HF_LAZY_PRELOAD_THRESHOLD as number)
: LAZY_THRESHOLD;
lazy = clips.length >= configuredThreshold;
if (lazy && !activationEmitted) {
activationEmitted = true;
options?.onActivation?.(clips.length);
}
}
function evictClip(clip: RuntimeMediaClip): void {
if (!promoted.has(clip.el)) return;
// Stash original src before clearing
if (!originalSrc.has(clip.el)) {
originalSrc.set(clip.el, clip.el.src);
}
// Release buffered data: only way to free memory per MDN
clip.el.removeAttribute("src");
clip.el.load();
clip.el.preload = "metadata";
promoted.delete(clip.el);
const idx = promotionOrder.indexOf(clip.el);
if (idx !== -1) promotionOrder.splice(idx, 1);
}
function promoteClip(clip: RuntimeMediaClip): void {
if (promoted.has(clip.el)) return;
// Restore src if previously evicted
const stashedSrc = originalSrc.get(clip.el);
if (stashedSrc !== undefined && !clip.el.src) {
clip.el.src = stashedSrc;
originalSrc.delete(clip.el);
}
promoted.add(clip.el);
promotionOrder.push(clip.el);
if (clip.el.preload !== "auto") {
clip.el.preload = "auto";
}
if (clip.el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
clip.el.load();
}
}
function evictOutsideWindow(inWindow: Set<RuntimeMediaClip>): void {
const windowEls = new Set<HTMLMediaElement>();
for (const clip of inWindow) {
windowEls.add(clip.el);
}
for (const clip of clips) {
if (promoted.has(clip.el) && !windowEls.has(clip.el)) {
evictClip(clip);
}
}
const maxPromoted = Math.min(
MAX_PROMOTED_CEIL,
MAX_PROMOTED_BASE + Math.floor(clips.length / 10),
);
while (promotionOrder.length > maxPromoted) {
const oldest = promotionOrder[0];
if (windowEls.has(oldest)) break;
const clip = clips.find((c) => c.el === oldest);
if (clip) {
evictClip(clip);
} else {
promoted.delete(oldest);
promotionOrder.shift();
}
}
}
function getClipsInWindow(timeSeconds: number): Set<RuntimeMediaClip> {
const windowStart = timeSeconds - LOOKBEHIND_SECONDS;
const windowEnd = timeSeconds + LOOKAHEAD_SECONDS;
const inWindow = new Set<RuntimeMediaClip>();
for (const clip of clips) {
const active = timeSeconds >= clip.start && timeSeconds < clip.end;
const inLookahead = clip.start >= timeSeconds && clip.start <= windowEnd;
const inLookbehind = clip.end > windowStart && clip.end <= timeSeconds;
if (active || inLookahead || inLookbehind) {
inWindow.add(clip);
}
}
if (inWindow.size < LOOKAHEAD_MIN_CLIPS) {
const sorted = clips
.filter((c) => c.start >= timeSeconds && !inWindow.has(c))
.sort((a, b) => a.start - b.start);
for (const clip of sorted) {
inWindow.add(clip);
if (inWindow.size >= LOOKAHEAD_MIN_CLIPS) break;
}
}
return inWindow;
}
function syncWindow(timeSeconds: number): void {
const window = getClipsInWindow(timeSeconds);
evictOutsideWindow(window);
for (const clip of clips) {
if (window.has(clip)) {
promoteClip(clip);
}
}
}
function sync(currentTimeSeconds: number): void {
if (!lazy) return;
syncWindow(currentTimeSeconds);
}
function preloadAroundTime(timeSeconds: number): void {
if (!lazy) return;
syncWindow(timeSeconds);
}
function isLazy(): boolean {
return lazy;
}
return { refresh, sync, preloadAroundTime, isLazy };
}
-2
View File
@@ -10,9 +10,7 @@ describe("createRuntimeState", () => {
expect(state.playbackRate).toBe(1);
expect(state.bridgeMuted).toBe(false);
expect(state.capturedTimeline).toBeNull();
expect(state.rafId).toBeNull();
expect(state.tornDown).toBe(false);
expect(state.parityModeEnabled).toBe(true);
});
it("returns independent instances", () => {
-10
View File
@@ -5,10 +5,8 @@ import type { TransportClock } from "./clock";
export type RuntimeState = {
capturedTimeline: RuntimeTimelineLike | null;
isPlaying: boolean;
rafId: number | null;
currentTime: number;
deterministicAdapters: RuntimeDeterministicAdapter[];
parityModeEnabled: boolean;
canonicalFps: number;
bridgeMuted: boolean;
bridgeVolume: number;
@@ -62,9 +60,7 @@ export type RuntimeState = {
*/
bridgeMaxPostIntervalMs: number;
controlBridgeHandler: ((event: MessageEvent) => void) | null;
clampDurationLoggedRaw: number | null;
beforeUnloadHandler: (() => void) | null;
domReadyHandler: (() => void) | null;
injectedCompStyles: HTMLStyleElement[];
injectedCompScripts: HTMLScriptElement[];
cachedTimedMediaEls: Array<HTMLVideoElement | HTMLAudioElement>;
@@ -72,7 +68,6 @@ export type RuntimeState = {
cachedVideoClips: RuntimeMediaClip[];
cachedMediaTimelineDurationSeconds: number;
tornDown: boolean;
nativeVisualWatchdogTick: number;
/**
* Single-clock transport. The sole time authority GSAP is always
* paused and seeked to `clock.now()` on each rAF tick. Eliminates
@@ -87,10 +82,8 @@ export function createRuntimeState(): RuntimeState {
return {
capturedTimeline: null,
isPlaying: false,
rafId: null,
currentTime: 0,
deterministicAdapters: [],
parityModeEnabled: true,
canonicalFps: 30,
bridgeMuted: false,
bridgeVolume: 1,
@@ -104,9 +97,7 @@ export function createRuntimeState(): RuntimeState {
bridgeLastPostedMuted: false,
bridgeMaxPostIntervalMs: 80,
controlBridgeHandler: null,
clampDurationLoggedRaw: null,
beforeUnloadHandler: null,
domReadyHandler: null,
injectedCompStyles: [],
injectedCompScripts: [],
cachedTimedMediaEls: [],
@@ -114,7 +105,6 @@ export function createRuntimeState(): RuntimeState {
cachedVideoClips: [],
cachedMediaTimelineDurationSeconds: 0,
tornDown: false,
nativeVisualWatchdogTick: 0,
transportClock: null,
transportRafId: null,
};
+5 -24
View File
@@ -6,17 +6,14 @@ export type RuntimeJson =
| RuntimeJson[]
| { [key: string]: RuntimeJson };
import type { HyperframeControlAction } from "../inline-scripts/runtimeContract.js";
import type { HyperframePickerElementInfo } from "../inline-scripts/pickerApi.js";
export type RuntimeBridgeControlAction =
| "play"
| "pause"
| "seek"
| HyperframeControlAction
| "tick"
| "set-muted"
| "set-volume"
| "set-media-output-muted"
| "set-playback-rate"
| "enable-pick-mode"
| "disable-pick-mode"
| "flash-elements";
export type RuntimeBridgeControlMessage = {
@@ -85,23 +82,7 @@ export type RuntimeDiagnosticMessage = {
details: Record<string, RuntimeJson>;
};
export type RuntimePickerBoundingBox = {
x: number;
y: number;
width: number;
height: number;
};
export type RuntimePickerElementInfo = {
id: string | null;
tagName: string;
selector: string;
label: string;
boundingBox: RuntimePickerBoundingBox;
textContent: string | null;
src: string | null;
dataAttributes: Record<string, string>;
};
export type RuntimePickerElementInfo = HyperframePickerElementInfo;
export type RuntimePickerHoveredMessage = {
source: "hf-preview";
@@ -122,7 +122,7 @@ export function removeElementFromHtml(source: string, target: SourceMutationTarg
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
}
function isHTMLElement(el: Element): boolean {
export function isHTMLElement(el: Element): el is HTMLElement {
const HTMLEl = el.ownerDocument.defaultView?.HTMLElement;
return HTMLEl ? el instanceof HTMLEl : "style" in el;
}
@@ -283,7 +283,7 @@ export function patchElementInHtml(
const { document, wrappedFragment } = parseSourceDocument(source);
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el)) return { html: source, matched: false };
const htmlEl = el as unknown as HTMLElement;
const htmlEl = el;
for (const op of operations) {
switch (op.type) {
@@ -320,7 +320,7 @@ export function patchElementInHtml(
case "text-content":
if (op.value != null) {
const inner = htmlEl.children.length === 1 ? htmlEl.firstElementChild : null;
const textTarget = inner ? (inner as unknown as HTMLElement) : htmlEl;
const textTarget = inner && isHTMLElement(inner) ? inner : htmlEl;
textTarget.textContent = op.value;
}
break;
+34 -45
View File
@@ -29,6 +29,7 @@ import {
patchElementInHtml,
probeElementInSource,
splitElementInHtml,
isHTMLElement,
type PatchOperation,
} from "../helpers/sourceMutation.js";
import { parseHTML } from "linkedom";
@@ -265,7 +266,8 @@ function stripStudioEditsFromTarget(document: Document, selector: string): numbe
try {
for (const el of document.querySelectorAll(selector)) {
if (!el.getAttribute("data-hf-studio-path-offset")) continue;
const htmlEl = el as unknown as HTMLElement;
if (!isHTMLElement(el)) continue;
const htmlEl = el;
const originalTranslate = el.getAttribute("data-hf-studio-original-inline-translate");
htmlEl.style.removeProperty("--hf-studio-offset-x");
htmlEl.style.removeProperty("--hf-studio-offset-y");
@@ -285,33 +287,29 @@ function stripStudioEditsFromTarget(document: Document, selector: string): numbe
return stripped;
}
function lastKeyframeOpacity(kfs: GsapAnimation["keyframes"]): number | string | undefined {
if (!kfs) return undefined;
for (let i = kfs.keyframes.length - 1; i >= 0; i--) {
if ("opacity" in kfs.keyframes[i]!.properties) return kfs.keyframes[i]!.properties.opacity;
}
return undefined;
}
function resolveFinalOpacity(anim: GsapAnimation): number | null {
if (anim.method === "from") return null;
const raw = anim.keyframes ? lastKeyframeOpacity(anim.keyframes) : anim.properties.opacity;
if (raw == null) return null;
if (typeof raw === "string" && /^[+\-*]=/.test(raw)) return null;
const num = Number(raw);
return Number.isFinite(num) && num !== 0 ? num : null;
}
function bakeVisibilityOnDelete(document: Document, anim: GsapAnimation): void {
let finalOpacity: number | string | undefined;
if (anim.method === "from") {
return;
}
if (anim.keyframes) {
const kfs = anim.keyframes.keyframes;
for (let i = kfs.length - 1; i >= 0; i--) {
if ("opacity" in kfs[i]!.properties) {
finalOpacity = kfs[i]!.properties.opacity;
break;
}
}
} else if ("opacity" in anim.properties) {
finalOpacity = anim.properties.opacity;
}
if (finalOpacity == null) {
return;
}
if (typeof finalOpacity === "string" && /^[+\-*]=/.test(finalOpacity)) {
return;
}
const numOpacity = Number(finalOpacity);
if (!Number.isFinite(numOpacity) || numOpacity === 0) return;
const opacity = resolveFinalOpacity(anim);
if (opacity === null) return;
try {
for (const el of document.querySelectorAll(anim.targetSelector)) {
(el as unknown as HTMLElement).style.setProperty("opacity", String(numOpacity));
if (isHTMLElement(el)) el.style.setProperty("opacity", String(opacity));
}
} catch {
// Invalid selector — skip silently.
@@ -522,18 +520,22 @@ async function executeGsapMutation(
}
switch (body.type) {
case "update-property": {
case "update-property":
case "add-property": {
const r = requireAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
const val = body.type === "update-property" ? body.value : body.defaultValue;
return updateAnimationInScript(block.scriptText, body.animationId, {
properties: { ...r.anim.properties, [body.property]: body.value },
properties: { ...r.anim.properties, [body.property]: val },
});
}
case "update-from-property": {
case "update-from-property":
case "add-from-property": {
const r = requireFromToAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
const val = body.type === "update-from-property" ? body.value : body.defaultValue;
return updateAnimationInScript(block.scriptText, body.animationId, {
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.value },
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: val },
});
}
case "update-meta": {
@@ -573,20 +575,6 @@ async function executeGsapMutation(
}
return script;
}
case "add-property": {
const r = requireAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
return updateAnimationInScript(block.scriptText, body.animationId, {
properties: { ...r.anim.properties, [body.property]: body.defaultValue },
});
}
case "add-from-property": {
const r = requireFromToAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
return updateAnimationInScript(block.scriptText, body.animationId, {
fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.defaultValue },
});
}
case "remove-property": {
const r = requireAnimation(block.scriptText, body.animationId);
if ("err" in r) return r.err;
@@ -790,8 +778,9 @@ async function processUploadedFiles(
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
let n = 2;
while (n < 10000 && existsSync(resolve(targetDir, `${base} (${n})${ext}`))) n++;
if (n >= 10000) {
const MAX_COPY_INDEX = 10000;
while (n < MAX_COPY_INDEX && existsSync(resolve(targetDir, `${base} (${n})${ext}`))) n++;
if (n >= MAX_COPY_INDEX) {
skipped.push(name);
continue;
}
+2 -44
View File
@@ -1,10 +1,9 @@
import { execFileSync } from "node:child_process";
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
import { platform } from "node:os";
import type { Hono } from "hono";
import {
collectFontFileEntries,
fontDirectories,
getSystemProfilerFamilies,
locateSystemFont,
SYSTEM_FONT_SIZE_LIMIT,
} from "../../fonts/systemFontLocator";
@@ -42,47 +41,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function collectMacSystemProfilerFonts(): string[] {
if (platform() !== "darwin") return [];
let parsed: unknown;
try {
const raw = execFileSync("system_profiler", ["SPFontsDataType", "-json"], {
encoding: "utf8",
maxBuffer: 12 * 1024 * 1024,
timeout: 5000,
});
parsed = JSON.parse(raw);
} catch {
return [];
}
if (!isRecord(parsed) || !Array.isArray(parsed.SPFontsDataType)) return [];
const fonts: string[] = [];
for (const fontEntry of parsed.SPFontsDataType) {
if (!isRecord(fontEntry)) continue;
const typefaces = fontEntry.typefaces;
if (!Array.isArray(typefaces)) continue;
for (const typeface of typefaces) {
if (!isRecord(typeface)) continue;
const family = typeface.family;
const fullName = typeface.fullname;
const name = typeface._name;
if (typeof family === "string" && family.trim()) {
fonts.push(family.trim());
} else if (typeof fullName === "string" && fullName.trim()) {
fonts.push(fullName.trim());
} else if (typeof name === "string" && name.trim()) {
fonts.push(name.trim());
}
}
}
return fonts;
}
function collectFontsFromDir(dir: string): string[] {
return collectFontFileEntries(dir).map((e) => e.family);
}
@@ -91,7 +49,7 @@ function listInstalledFontFamilies(): string[] {
if (cachedFonts) return cachedFonts;
const families = new Set<string>();
for (const family of collectMacSystemProfilerFonts()) {
for (const family of getSystemProfilerFamilies()) {
families.add(family);
if (families.size >= MAX_FONT_RESULTS) break;
}