feat(sdk): variable usage scan + preview-values adapter seam

This commit is contained in:
James
2026-07-09 13:31:02 -07:00
parent 839881a98e
commit 91831f3f8e
11 changed files with 508 additions and 1 deletions
+9
View File
@@ -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;
}
+4
View File
@@ -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);
+2
View File
@@ -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";
+80 -1
View File
@@ -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);
});
});
+31
View File
@@ -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