mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(sdk): variable usage scan + preview-values adapter seam
This commit is contained in:
@@ -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