mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -320,11 +320,26 @@ export function generateHyperframesHtml(
|
||||
? ` data-zoom-keyframes='${JSON.stringify(stageZoomKeyframes).replace(/'/g, "'")}'`
|
||||
: "";
|
||||
|
||||
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, "'")}'`
|
||||
: "";
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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});`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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" } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 (4–5 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 };
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user