Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/parsers/src/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export {
isCompositionVariable,
isScalarVariableValue,
} from "./compositionVariables.js";
export { scanVariableUsage, type VariableUsageScan } from "./variableUsage.js";
1 change: 1 addition & 0 deletions packages/parsers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
78 changes: 78 additions & 0 deletions packages/parsers/src/variableUsage.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
172 changes: 172 additions & 0 deletions packages/parsers/src/variableUsage.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
9 changes: 9 additions & 0 deletions packages/sdk/src/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,13 @@ export interface PreviewAdapter {
// Stage 8 prep: fired when the preview host changes selection (e.g. user clicks an element).
// Not wired up in stage 7 — callers listen to the session's own selectionchange event instead.
on(event: "selection", handler: (ids: string[]) => void): () => 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 changes: 4 additions & 0 deletions packages/sdk/src/engine/mutate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,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);
Expand Down Expand Up @@ -662,6 +663,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);
Expand Down Expand Up @@ -1588,6 +1590,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(
Expand Down Expand Up @@ -1623,6 +1626,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);
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Loading
Loading