mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 20:18:35 +00:00
Merge pull request #2048 from heygen-com/07-07-feat_sdk_variable_usage_scan_preview-values_adapter_seam
feat(sdk): variable usage scan + preview-values adapter seam
This commit is contained in:
@@ -23,7 +23,9 @@ export {
|
||||
parseCompositionVariables,
|
||||
isCompositionVariable,
|
||||
isScalarVariableValue,
|
||||
scanVariableUsage,
|
||||
} from "@hyperframes/parsers/composition";
|
||||
export type { VariableUsageScan } from "@hyperframes/parsers/composition";
|
||||
|
||||
export { getVariables, readDeclaredDefaults } from "./runtime/getVariables.js";
|
||||
export {
|
||||
|
||||
@@ -15,3 +15,4 @@ export {
|
||||
isCompositionVariable,
|
||||
isScalarVariableValue,
|
||||
} from "./compositionVariables.js";
|
||||
export { scanVariableUsage, type VariableUsageScan } from "./variableUsage.js";
|
||||
|
||||
@@ -12,6 +12,7 @@ export { queryByAttr } from "./utils/cssSelector.js";
|
||||
// path helpers live behind the ./asset-paths subpath to keep this entry
|
||||
// browser-safe.
|
||||
export { decodeUrlPathVariants } from "./utils/urlPath.js";
|
||||
export { scanVariableUsage, type VariableUsageScan } from "./variableUsage.js";
|
||||
export {
|
||||
FONT_ALIAS_MAP,
|
||||
FONT_ALIAS_KEYS,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { scanVariableUsage } from "./variableUsage.js";
|
||||
|
||||
describe("scanVariableUsage", () => {
|
||||
it("collects ids from direct destructuring with defaults", () => {
|
||||
const scan = scanVariableUsage(`
|
||||
const { title = "Untitled", accent } = __hyperframes.getVariables();
|
||||
document.querySelector("h1").textContent = title;
|
||||
`);
|
||||
expect(scan.usedIds).toEqual(["title", "accent"]);
|
||||
expect(scan.scanIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("handles bare getVariables (sub-comp scoped shadow) and window-qualified calls", () => {
|
||||
expect(scanVariableUsage(`const { a } = getVariables();`).usedIds).toEqual(["a"]);
|
||||
expect(scanVariableUsage(`const { b } = window.__hyperframes.getVariables();`).usedIds).toEqual(
|
||||
["b"],
|
||||
);
|
||||
});
|
||||
|
||||
it("collects ids from member access on the call and on an alias", () => {
|
||||
const scan = scanVariableUsage(`
|
||||
const x = __hyperframes.getVariables().headline;
|
||||
const vars = __hyperframes.getVariables();
|
||||
el.style.color = vars.accent;
|
||||
const size = vars["font-size"];
|
||||
const { title } = vars;
|
||||
`);
|
||||
expect(scan.usedIds).toEqual(["headline", "accent", "font-size", "title"]);
|
||||
expect(scan.scanIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("collects string-literal destructuring keys", () => {
|
||||
const scan = scanVariableUsage(`const { "kebab-id": kebab } = getVariables();`);
|
||||
expect(scan.usedIds).toEqual(["kebab-id"]);
|
||||
expect(scan.scanIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("flags dynamic access as incomplete without losing static ids", () => {
|
||||
const scan = scanVariableUsage(`
|
||||
const vars = getVariables();
|
||||
const known = vars.known;
|
||||
const dynamic = vars[someKey];
|
||||
`);
|
||||
expect(scan.usedIds).toEqual(["known"]);
|
||||
expect(scan.scanIncomplete).toBe(true);
|
||||
});
|
||||
|
||||
it("flags rest spreads, escaping values, and chained aliases", () => {
|
||||
expect(scanVariableUsage(`const { a, ...rest } = getVariables();`).scanIncomplete).toBe(true);
|
||||
expect(scanVariableUsage(`render(getVariables());`).scanIncomplete).toBe(true);
|
||||
expect(
|
||||
scanVariableUsage(`const vars = getVariables(); const v2 = vars; use(v2.x);`).scanIncomplete,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags unparseable scripts as incomplete", () => {
|
||||
const scan = scanVariableUsage(`const { = broken`);
|
||||
expect(scan.usedIds).toEqual([]);
|
||||
expect(scan.scanIncomplete).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores unrelated code and same-named object keys", () => {
|
||||
const scan = scanVariableUsage(`
|
||||
const vars = getVariables();
|
||||
const config = { vars: 1, other: vars.real };
|
||||
gsap.timeline({ paused: true });
|
||||
`);
|
||||
expect(scan.usedIds).toEqual(["real"]);
|
||||
expect(scan.scanIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty for scripts that never touch variables", () => {
|
||||
const scan = scanVariableUsage(`gsap.timeline({ paused: true }).to(".x", { opacity: 1 });`);
|
||||
expect(scan.usedIds).toEqual([]);
|
||||
expect(scan.scanIncomplete).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Browser-safe static scan for composition-variable reads in script text.
|
||||
*
|
||||
* Compositions read variables by calling the runtime API — `getVariables()`
|
||||
* bare (sub-comp scoped shadow) or via `__hyperframes.getVariables()` /
|
||||
* `window.__hyperframes.getVariables()` — and there is no DOM-attribute
|
||||
* binding to scan, so "which variables does this composition use" can only be
|
||||
* derived from the scripts. This is a best-effort static analysis: the
|
||||
* patterns agents actually write (destructuring, member access, a single
|
||||
* alias variable) resolve to ids; anything opaque flips `scanIncomplete`
|
||||
* so consumers can present usage as a lower bound instead of a fact.
|
||||
*
|
||||
* AST nodes are handled untyped (same convention as gsapParserAcorn.ts) —
|
||||
* acorn's structural types don't survive acorn-walk's visitor signatures.
|
||||
*/
|
||||
|
||||
import * as acorn from "acorn";
|
||||
import * as acornWalk from "acorn-walk";
|
||||
|
||||
export interface VariableUsageScan {
|
||||
/** Variable ids statically read by the script, in first-seen order. */
|
||||
usedIds: string[];
|
||||
/**
|
||||
* True when the script accesses variables in a way the scan cannot resolve
|
||||
* (computed keys, rest spreads, the values object escaping into a call…) or
|
||||
* when the script fails to parse — usedIds is then a lower bound.
|
||||
*/
|
||||
scanIncomplete: boolean;
|
||||
}
|
||||
|
||||
interface Sink {
|
||||
use(id: string): void;
|
||||
incomplete(): void;
|
||||
}
|
||||
|
||||
// oxlint-disable no-explicit-any -- untyped acorn AST traversal, see header
|
||||
|
||||
function isGetVariablesCallee(callee: any): boolean {
|
||||
if (callee?.type === "Identifier") return callee.name === "getVariables";
|
||||
if (callee?.type === "MemberExpression" && !callee.computed) {
|
||||
return callee.property?.type === "Identifier" && callee.property.name === "getVariables";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Collect ids from an ObjectPattern destructuring of the values object. */
|
||||
// Exhaustive AST-node classification — branchy by nature, same as gsapParserAcorn.
|
||||
// fallow-ignore-next-line complexity
|
||||
function collectFromObjectPattern(pattern: any, out: Sink): void {
|
||||
for (const prop of pattern.properties ?? []) {
|
||||
if (prop?.type === "RestElement") {
|
||||
out.incomplete();
|
||||
continue;
|
||||
}
|
||||
if (prop?.type !== "Property") continue;
|
||||
if (prop.computed === true) {
|
||||
out.incomplete();
|
||||
} else if (prop.key?.type === "Identifier") {
|
||||
out.use(String(prop.key.name));
|
||||
} else if (prop.key?.type === "Literal" && typeof prop.key.value === "string") {
|
||||
out.use(prop.key.value);
|
||||
} else {
|
||||
out.incomplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect an id from a MemberExpression reading the values object. */
|
||||
function collectFromMemberAccess(member: any, out: Sink): void {
|
||||
if (member.computed !== true && member.property?.type === "Identifier") {
|
||||
out.use(String(member.property.name));
|
||||
} else if (
|
||||
member.computed === true &&
|
||||
member.property?.type === "Literal" &&
|
||||
typeof member.property.value === "string"
|
||||
) {
|
||||
out.use(member.property.value);
|
||||
} else {
|
||||
out.incomplete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one read of the values object (a getVariables() call result or an
|
||||
* alias holding it) by its immediate syntactic context. Returns the alias
|
||||
* name when the value is bound to a plain variable (`const vars = …`).
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
function classifyValueRead(parent: any, valueNode: any, out: Sink): string | null {
|
||||
if (!parent || parent.type === "ExpressionStatement") {
|
||||
// Bare statement — value unused, nothing read.
|
||||
return null;
|
||||
}
|
||||
if (parent.type === "MemberExpression" && parent.object === valueNode) {
|
||||
collectFromMemberAccess(parent, out);
|
||||
return null;
|
||||
}
|
||||
if (parent.type === "VariableDeclarator" && parent.init === valueNode) {
|
||||
if (parent.id?.type === "ObjectPattern") {
|
||||
collectFromObjectPattern(parent.id, out);
|
||||
return null;
|
||||
}
|
||||
if (parent.id?.type === "Identifier") return String(parent.id.name);
|
||||
out.incomplete();
|
||||
return null;
|
||||
}
|
||||
// The values object escapes (argument, return, spread, assignment…) —
|
||||
// reads beyond this point are invisible to the scan.
|
||||
out.incomplete();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function scanVariableUsage(scriptText: string): VariableUsageScan {
|
||||
const usedIds: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let scanIncomplete = false;
|
||||
|
||||
const sink: Sink = {
|
||||
use(id: string) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
usedIds.push(id);
|
||||
}
|
||||
},
|
||||
incomplete() {
|
||||
scanIncomplete = true;
|
||||
},
|
||||
};
|
||||
|
||||
let ast: any;
|
||||
try {
|
||||
ast = acorn.parse(scriptText, { ecmaVersion: "latest", sourceType: "script" });
|
||||
} catch {
|
||||
return { usedIds: [], scanIncomplete: true };
|
||||
}
|
||||
|
||||
const aliases = new Set<string>();
|
||||
|
||||
// Pass 1: classify every getVariables() call by its parent context.
|
||||
acornWalk.ancestor(ast, {
|
||||
CallExpression(node: any, _: unknown, ancestors: any[]) {
|
||||
if (!isGetVariablesCallee(node.callee)) return;
|
||||
const parent = ancestors[ancestors.length - 2];
|
||||
const alias = classifyValueRead(parent, node, sink);
|
||||
if (alias) aliases.add(alias);
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Pass 2: classify every reference to an alias of the values object.
|
||||
// Scope-naive by design: an unrelated same-named identifier can only make
|
||||
// the scan report extra ids or flip scanIncomplete, never miss a read.
|
||||
if (aliases.size > 0) {
|
||||
acornWalk.ancestor(ast, {
|
||||
// fallow-ignore-next-line complexity
|
||||
Identifier(node: any, _: unknown, ancestors: any[]) {
|
||||
if (!aliases.has(String(node.name))) return;
|
||||
const parent = ancestors[ancestors.length - 2];
|
||||
if (!parent) return;
|
||||
// Skip the declarator that introduced the alias and property-position
|
||||
// identifiers that merely share the name.
|
||||
if (parent.type === "VariableDeclarator" && parent.id === node) return;
|
||||
if (parent.type === "MemberExpression" && parent.property === node) return;
|
||||
if (parent.type === "Property" && parent.key === node && parent.computed !== true) return;
|
||||
// Chained aliases (const v2 = vars) are not followed — flag instead
|
||||
// of silently missing reads through the second name.
|
||||
if (classifyValueRead(parent, node, sink)) sink.incomplete();
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
|
||||
return { usedIds, scanIncomplete };
|
||||
}
|
||||
@@ -84,4 +84,13 @@ export interface PreviewAdapter {
|
||||
* subscription first. Returns an unsubscribe.
|
||||
*/
|
||||
attachSync(comp: Composition): () => void;
|
||||
|
||||
/**
|
||||
* Optional: apply composition-variable values to the preview so it renders
|
||||
* as `window.__hfVariables` injection would at render time (values must be
|
||||
* visible to the runtime BEFORE composition scripts run — typically a
|
||||
* preview reload with injection, not a live poke). Pass null to restore
|
||||
* declared defaults. Values are ephemeral preview state, never persisted.
|
||||
*/
|
||||
setPreviewVariables?(values: Record<string, unknown> | null): void;
|
||||
}
|
||||
|
||||
@@ -597,6 +597,7 @@ function handleSetTiming(
|
||||
}
|
||||
|
||||
// Flush accumulated GSAP script changes as a single patch pair.
|
||||
// fallow-ignore-next-line code-duplication
|
||||
if (origScript && currentScript && currentScript !== origScript) {
|
||||
setGsapScript(parsed.document, currentScript);
|
||||
const gsapResult = gsapScriptChange(origScript, currentScript);
|
||||
@@ -666,6 +667,7 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
if (origScript && currentScript && currentScript !== origScript) {
|
||||
setGsapScript(parsed.document, currentScript);
|
||||
const gsapResult = gsapScriptChange(origScript, currentScript);
|
||||
@@ -1629,6 +1631,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
||||
case "removeElement": {
|
||||
const ids = targets(op.target);
|
||||
if (ids.length === 0) return canErr("E_TARGET_NOT_FOUND", "No target ids provided.");
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const missing = ids.filter((id) => resolveScoped(parsed.document, id) === null);
|
||||
if (missing.length > 0)
|
||||
return canErr(
|
||||
@@ -1664,6 +1667,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
||||
}
|
||||
case "reorderElements": {
|
||||
if (op.entries.length === 0) return CAN_OK;
|
||||
// fallow-ignore-next-line code-duplication
|
||||
const missing = op.entries
|
||||
.map((e) => e.target)
|
||||
.filter((id) => resolveScoped(parsed.document, id) === null);
|
||||
|
||||
@@ -28,7 +28,9 @@ export type {
|
||||
CompositionVariable,
|
||||
CompositionVariableType,
|
||||
VariableValidationIssue,
|
||||
VariableUsageScan,
|
||||
} from "@hyperframes/core/variables";
|
||||
export type { VariableUsageReport } from "./types.js";
|
||||
|
||||
export { UnsupportedOpError } from "./engine/mutate.js";
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
PersistErrorEvent,
|
||||
SelectionProxy,
|
||||
ElementHandle,
|
||||
VariableUsageReport,
|
||||
} from "./types.js";
|
||||
import { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
|
||||
import { buildRoots, flatElements, parsedAnimationIds } from "./document.js";
|
||||
@@ -39,7 +40,11 @@ import { readVariableDefault, listVariableDecls } from "./engine/variableModel.j
|
||||
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
|
||||
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document";
|
||||
import { parseStartExpression } from "@hyperframes/core/runtime/start-expression";
|
||||
import { readDeclaredDefaults, validateVariables } from "@hyperframes/core/variables";
|
||||
import {
|
||||
readDeclaredDefaults,
|
||||
validateVariables,
|
||||
scanVariableUsage,
|
||||
} from "@hyperframes/core/variables";
|
||||
import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables";
|
||||
import { readVariableDeclarations } from "./engine/variableModel.js";
|
||||
import { serializeDocument } from "./engine/serialize.js";
|
||||
@@ -71,6 +76,11 @@ export interface OpenCompositionOptions {
|
||||
|
||||
// ─── Implementation ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Escape a string for literal use inside a RegExp. */
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
class CompositionImpl implements Composition {
|
||||
private readonly parsed: ParsedDocument;
|
||||
private readonly persist: PersistAdapter | undefined;
|
||||
@@ -220,6 +230,75 @@ class CompositionImpl implements Composition {
|
||||
return validateVariables(values, this.getVariableDeclarations());
|
||||
}
|
||||
|
||||
/**
|
||||
* Script scans are content-keyed (same rationale as _gsapLabelCache): the
|
||||
* panel recomputes usage on every preview reload, and unchanged script text
|
||||
* is the common case — never pay a second acorn parse for identical input.
|
||||
*/
|
||||
private _variableUsageScanCache = new Map<string, ReturnType<typeof scanVariableUsage>>();
|
||||
|
||||
// Scan/merge dispatcher — same complexity class as the suppressed
|
||||
// variableUsage.ts classifiers it drives.
|
||||
// fallow-ignore-next-line complexity
|
||||
getVariableUsage(): VariableUsageReport {
|
||||
const usedIds: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let scanIncomplete = false;
|
||||
const freshCache = new Map<string, ReturnType<typeof scanVariableUsage>>();
|
||||
// Inline scripts only — external src scripts aren't part of the document model.
|
||||
for (const script of Array.from(this.parsed.document.querySelectorAll("script"))) {
|
||||
if (script.getAttribute("src")) continue;
|
||||
const text = script.textContent ?? "";
|
||||
// Direct global reads (window.__hfVariables / __hfVariablesByComp) are
|
||||
// invisible to the getVariables() scanner — the report must degrade to
|
||||
// a lower bound instead of confidently claiming declarations unused.
|
||||
if (text.includes("__hfVariables")) scanIncomplete = true;
|
||||
if (!text.includes("getVariables")) continue; // cheap pre-filter before an acorn parse
|
||||
const scan = this._variableUsageScanCache.get(text) ?? scanVariableUsage(text);
|
||||
freshCache.set(text, scan);
|
||||
scanIncomplete = scanIncomplete || scan.scanIncomplete;
|
||||
for (const id of scan.usedIds) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
usedIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
this._variableUsageScanCache = freshCache;
|
||||
const declaredIds = this.getVariableDeclarations().map((d) => d.id);
|
||||
// The CSS compat channel counts as usage: a variable consumed only via
|
||||
// var(--id) in stylesheets or inline styles must not be badged unused
|
||||
// (removing it also removes the --{id} root prop and breaks the binding).
|
||||
const cssParts: string[] = [];
|
||||
for (const styleEl of Array.from(this.parsed.document.querySelectorAll("style"))) {
|
||||
cssParts.push(styleEl.textContent ?? "");
|
||||
}
|
||||
for (const el of Array.from(this.parsed.document.querySelectorAll("[style]"))) {
|
||||
cssParts.push(el.getAttribute("style") ?? "");
|
||||
}
|
||||
const cssText = cssParts.join("\n");
|
||||
// Match var(--id) only at a custom-property-name boundary: the id must be
|
||||
// followed by whitespace, a comma (fallback), or the closing paren — so id
|
||||
// "foo" is NOT counted as used by an unrelated var(--foo-header). Ids are
|
||||
// regex-escaped because a value read from disk may predate can()'s
|
||||
// /^[A-Za-z_][A-Za-z0-9_-]*$/ enforcement and carry metacharacters.
|
||||
const cssUsed = (id: string) =>
|
||||
new RegExp(`var\\(\\s*--${escapeRegExp(id)}[\\s,)]`).test(cssText);
|
||||
const declaredSet = new Set(declaredIds);
|
||||
return {
|
||||
usedIds,
|
||||
unusedDeclarations: declaredIds.filter((id) => !seen.has(id) && !cssUsed(id)),
|
||||
undeclaredReads: usedIds.filter((id) => !declaredSet.has(id)),
|
||||
scanIncomplete,
|
||||
};
|
||||
}
|
||||
|
||||
setPreviewVariables(values: Record<string, unknown> | null): boolean {
|
||||
if (!this.preview?.setPreviewVariables) return false;
|
||||
this.preview.setPreviewVariables(values);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── WS-C: timing accessors + typed setHold ───────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* getVariableUsage (declaration ↔ script-scan cross-reference) and
|
||||
* setPreviewVariables (preview adapter delegation).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { openComposition } from "./session.js";
|
||||
import type { PreviewAdapter } from "./adapters/types.js";
|
||||
|
||||
const DECLS = JSON.stringify([
|
||||
{ id: "title", type: "string", label: "Title", default: "Hello" },
|
||||
{ id: "accent", type: "color", label: "Accent", default: "#00C3FF" },
|
||||
{ id: "orphan", type: "string", label: "Never read", default: "x" },
|
||||
]);
|
||||
|
||||
function doc(script: string, decls: string | null = DECLS): string {
|
||||
const attr = decls ? ` data-composition-variables='${decls}'` : "";
|
||||
return `<!DOCTYPE html>
|
||||
<html${attr}>
|
||||
<body>
|
||||
<div data-hf-id="hf-stage" data-hf-root data-duration="5">
|
||||
<h1 data-hf-id="hf-title">Hello</h1>
|
||||
</div>
|
||||
<script>${script}</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
describe("getVariableUsage", () => {
|
||||
it("cross-references used, unused, and undeclared ids", async () => {
|
||||
const comp = await openComposition(
|
||||
doc(`
|
||||
const { title, ghost } = __hyperframes.getVariables();
|
||||
document.querySelector("h1").textContent = title;
|
||||
const vars = __hyperframes.getVariables();
|
||||
el.style.color = vars.accent;
|
||||
`),
|
||||
);
|
||||
const usage = comp.getVariableUsage();
|
||||
expect(usage.usedIds).toEqual(["title", "ghost", "accent"]);
|
||||
expect(usage.unusedDeclarations).toEqual(["orphan"]);
|
||||
expect(usage.undeclaredReads).toEqual(["ghost"]);
|
||||
expect(usage.scanIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("reports all declarations unused when no script reads variables", async () => {
|
||||
const comp = await openComposition(doc(`gsap.timeline({ paused: true });`));
|
||||
const usage = comp.getVariableUsage();
|
||||
expect(usage.usedIds).toEqual([]);
|
||||
expect(usage.unusedDeclarations).toEqual(["title", "accent", "orphan"]);
|
||||
expect(usage.scanIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("propagates scanIncomplete from opaque access", async () => {
|
||||
const comp = await openComposition(
|
||||
doc(`const vars = getVariables(); const v = vars[pickKey()];`),
|
||||
);
|
||||
expect(comp.getVariableUsage().scanIncomplete).toBe(true);
|
||||
});
|
||||
|
||||
it("counts a variable used only via var(--id) in a <style> block as used", async () => {
|
||||
const comp = await openComposition(
|
||||
doc(`gsap.timeline({ paused: true });`).replace(
|
||||
"<body>",
|
||||
`<body><style>.stage { background: var(--accent); }</style>`,
|
||||
),
|
||||
);
|
||||
const usage = comp.getVariableUsage();
|
||||
// accent is CSS-consumed, so it must NOT be badged unused.
|
||||
expect(usage.unusedDeclarations).toEqual(["title", "orphan"]);
|
||||
});
|
||||
|
||||
it("does not count var(--id) as usage of a prefix-extended custom property", async () => {
|
||||
// `accent` must not be marked used by an unrelated `var(--accent-shadow)`.
|
||||
const comp = await openComposition(
|
||||
doc(`gsap.timeline({ paused: true });`).replace(
|
||||
"<body>",
|
||||
`<body><style>.stage { box-shadow: 0 0 4px var(--accent-shadow); }</style>`,
|
||||
),
|
||||
);
|
||||
const usage = comp.getVariableUsage();
|
||||
expect(usage.unusedDeclarations).toContain("accent");
|
||||
});
|
||||
|
||||
it("handles compositions with no declarations and no scripts", async () => {
|
||||
const comp = await openComposition(
|
||||
`<!DOCTYPE html><html><body><div data-hf-id="hf-stage" data-hf-root data-duration="5"><p data-hf-id="hf-p">x</p></div></body></html>`,
|
||||
);
|
||||
expect(comp.getVariableUsage()).toEqual({
|
||||
usedIds: [],
|
||||
unusedDeclarations: [],
|
||||
undeclaredReads: [],
|
||||
scanIncomplete: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPreviewVariables", () => {
|
||||
function makeAdapter(calls: Array<Record<string, unknown> | null>): PreviewAdapter {
|
||||
return {
|
||||
elementAtPoint: () => null,
|
||||
applyDraft: () => {},
|
||||
commitPreview: () => {},
|
||||
cancelPreview: () => {},
|
||||
select: () => {},
|
||||
on: () => () => {},
|
||||
setPreviewVariables: (values) => calls.push(values),
|
||||
};
|
||||
}
|
||||
|
||||
it("delegates to the preview adapter and reports handling", async () => {
|
||||
const calls: Array<Record<string, unknown> | null> = [];
|
||||
const comp = await openComposition(doc(""), { preview: makeAdapter(calls) });
|
||||
expect(comp.setPreviewVariables({ title: "Custom" })).toBe(true);
|
||||
expect(comp.setPreviewVariables(null)).toBe(true);
|
||||
expect(calls).toEqual([{ title: "Custom" }, null]);
|
||||
});
|
||||
|
||||
it("returns false without an adapter or without adapter support", async () => {
|
||||
const noAdapter = await openComposition(doc(""));
|
||||
expect(noAdapter.setPreviewVariables({ a: 1 })).toBe(false);
|
||||
|
||||
const bare = makeAdapter([]);
|
||||
delete bare.setPreviewVariables;
|
||||
const unsupported = await openComposition(doc(""), { preview: bare });
|
||||
expect(unsupported.setPreviewVariables({ a: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,24 @@
|
||||
import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables";
|
||||
|
||||
/**
|
||||
* Cross-referenced variable usage for a whole composition: the per-script
|
||||
* static scans merged and compared against the declared schema.
|
||||
*/
|
||||
export interface VariableUsageReport {
|
||||
/** Variable ids read by composition scripts (static analysis, first-seen order). */
|
||||
usedIds: string[];
|
||||
/** Declared ids never read by any script. */
|
||||
unusedDeclarations: string[];
|
||||
/** Ids read by scripts but missing from data-composition-variables. */
|
||||
undeclaredReads: string[];
|
||||
/**
|
||||
* True when any script accesses variables opaquely (computed keys, escaping
|
||||
* values object…) — usedIds is then a lower bound and unusedDeclarations
|
||||
* may be false positives.
|
||||
*/
|
||||
scanIncomplete: boolean;
|
||||
}
|
||||
|
||||
// ─── Document model ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Full DOM-level view of one editable element. Built by the SDK adaptation layer. */
|
||||
@@ -469,6 +488,18 @@ export interface Composition {
|
||||
* `--strict-variables`). Read-only — does not dispatch.
|
||||
*/
|
||||
validateVariableValues(values: Record<string, unknown>): VariableValidationIssue[];
|
||||
/**
|
||||
* Cross-reference the declared schema against a static scan of every inline
|
||||
* composition script (getVariables() reads). Read-only — does not dispatch.
|
||||
*/
|
||||
getVariableUsage(): VariableUsageReport;
|
||||
/**
|
||||
* Apply variable values to the preview surface (ephemeral — never written
|
||||
* to the document; use setVariableValue to persist a default). Pass null to
|
||||
* restore declared defaults. No-op when the preview adapter doesn't
|
||||
* implement setPreviewVariables; returns whether the adapter handled it.
|
||||
*/
|
||||
setPreviewVariables(values: Record<string, unknown> | null): boolean;
|
||||
/**
|
||||
* Read enter/exit times and GSAP labels for every timed element (WS-C).
|
||||
* Derives enterAt/exitAt using the same data-duration vs data-end preference
|
||||
|
||||
Reference in New Issue
Block a user