Skip to content

Commit b0a0727

Browse files
committed
feat(sdk): variable declaration edit ops (declare/update/remove)
1 parent 1569719 commit b0a0727

10 files changed

Lines changed: 611 additions & 5 deletions

File tree

packages/core/src/variables.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type {
2121
export {
2222
COMPOSITION_VARIABLE_TYPES,
2323
parseCompositionVariables,
24+
isCompositionVariable,
2425
isScalarVariableValue,
2526
} from "@hyperframes/parsers/composition";
2627

packages/parsers/src/composition.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,8 @@ export {
1010
resolveAliasDisplayName,
1111
} from "./fontAliases.js";
1212
export { decodeUrlPathVariants } from "./utils/urlPath.js";
13-
export { parseCompositionVariables, isScalarVariableValue } from "./compositionVariables.js";
13+
export {
14+
parseCompositionVariables,
15+
isCompositionVariable,
16+
isScalarVariableValue,
17+
} from "./compositionVariables.js";

packages/parsers/src/compositionVariables.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@ function isVariableType(t: unknown): t is CompositionVariableType {
4040
return typeof t === "string" && t in DEFAULT_TYPEOF;
4141
}
4242

43-
function isCompositionVariable(v: unknown): v is CompositionVariable {
43+
/**
44+
* True when the value is a structurally valid variable declaration: id, label,
45+
* a known type, a default matching that type, and options[] for enums. The
46+
* same predicate parseCompositionVariables filters with — exported so writers
47+
* (SDK declaration ops, Studio forms) can validate before persisting.
48+
*/
49+
export function isCompositionVariable(v: unknown): v is CompositionVariable {
4450
if (!isRecord(v)) return false;
4551
if (typeof v.id !== "string" || typeof v.label !== "string") return false;
4652
if (!isVariableType(v.type)) return false;

packages/sdk/src/engine/apply-patches.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,21 @@ import {
1919
setStyleSheet,
2020
} from "./model.js";
2121
import { keyToPath, stylePath } from "./patches.js";
22-
import { writeVariableDefault, clearVariableDefault } from "./variableModel.js";
22+
import {
23+
writeVariableDefault,
24+
clearVariableDefault,
25+
writeVariableDeclaration,
26+
removeVariableDeclarationEntry,
27+
} from "./variableModel.js";
28+
29+
function isRawDeclarationEntry(value: unknown): value is { id: string } & Record<string, unknown> {
30+
return (
31+
typeof value === "object" &&
32+
value !== null &&
33+
!Array.isArray(value) &&
34+
typeof (value as { id?: unknown }).id === "string"
35+
);
36+
}
2337

2438
// ─── Path parser ────────────────────────────────────────────────────────────
2539

@@ -32,6 +46,7 @@ interface ParsedPath {
3246
| "hold"
3347
| "element"
3448
| "variable"
49+
| "variableDeclaration"
3550
| "metadata"
3651
| "script"
3752
| "stylesheet";
@@ -65,6 +80,9 @@ function parsePath(path: string): ParsedPath | null {
6580
const elemM = /^\/elements\/([^/]+)$/.exec(path);
6681
if (elemM) return { type: "element", id: elemM[1] };
6782

83+
const varDeclM = /^\/variableDeclarations\/(.+)$/.exec(path);
84+
if (varDeclM) return { type: "variableDeclaration", id: varDeclM[1] };
85+
6886
const varM = /^\/variables\/(.+)$/.exec(path);
6987
if (varM) return { type: "variable", id: varM[1] };
7088

@@ -229,6 +247,20 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo
229247
break;
230248
}
231249

250+
case "variableDeclaration": {
251+
if (!p.id) return;
252+
if (patch.op === "remove") {
253+
removeVariableDeclarationEntry(parsed.document, p.id);
254+
} else if (isRawDeclarationEntry(patch.value)) {
255+
// Replay is faithful, not strict: inverse patches capture raw entries
256+
// (loose hand-authored declarations included) and undo must restore
257+
// them verbatim — gating on isCompositionVariable here would make
258+
// undo of a remove/update on a loose entry silently no-op.
259+
writeVariableDeclaration(parsed.document, patch.value);
260+
}
261+
break;
262+
}
263+
232264
case "variable": {
233265
if (!p.id) return;
234266
// B1: update the JSON model (data-composition-variables) so

packages/sdk/src/engine/mutate.ts

Lines changed: 221 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
holdPath,
4141
elementPath,
4242
variablePath,
43+
variableDeclPath,
4344
metaPath,
4445
gsapScriptPath,
4546
styleSheetPath,
@@ -76,7 +77,18 @@ import {
7677
unrollDynamicAnimations,
7778
} from "@hyperframes/core/gsap-writer-acorn";
7879
import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
79-
import { readVariableDefault, writeVariableDefault } from "./variableModel.js";
80+
import {
81+
readVariableDefault,
82+
writeVariableDefault,
83+
findVariableDeclaration,
84+
writeVariableDeclaration,
85+
removeVariableDeclarationEntry,
86+
} from "./variableModel.js";
87+
import {
88+
isCompositionVariable,
89+
isScalarVariableValue as isScalar,
90+
} from "@hyperframes/core/variables";
91+
import type { CompositionVariable } from "@hyperframes/core/variables";
8092
import {
8193
URI_BEARING_ATTRS,
8294
DANGEROUS_URI_SCHEMES,
@@ -288,6 +300,12 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
288300
return handleSetCompositionMetadata(parsed, op);
289301
case "setVariableValue":
290302
return handleSetVariableValue(parsed, op.id, op.value);
303+
case "declareVariable":
304+
return handleDeclareVariable(parsed, op.declaration);
305+
case "updateVariableDeclaration":
306+
return handleUpdateVariableDeclaration(parsed, op.id, op.declaration);
307+
case "removeVariableDeclaration":
308+
return handleRemoveVariableDeclaration(parsed, op.id);
291309
case "setClassStyle":
292310
return handleSetClassStyle(parsed, op.selector, op.styles);
293311
case "addLabel":
@@ -885,6 +903,169 @@ function handleSetVariableValue(
885903
return { forward, inverse };
886904
}
887905

906+
/**
907+
* Keep the `--{id}` CSS compat custom property on the root in sync with a
908+
* scalar default (same secondary channel handleSetVariableValue maintains).
909+
* Pass null to clear. Returns the patch pair, or null when there is no root
910+
* or nothing to change.
911+
*/
912+
function cssCompatChange(
913+
parsed: ParsedDocument,
914+
id: string,
915+
newVal: string | null,
916+
): { forward: JsonPatchOp; inverse: JsonPatchOp } | null {
917+
const root = findRoot(parsed.document);
918+
const rootId = root?.getAttribute("data-hf-id");
919+
if (!root || !rootId) return null;
920+
const cssVar = `--${id}`;
921+
const oldCssValue = getElementStyles(root)[cssVar] ?? null;
922+
if (newVal !== null) {
923+
if (oldCssValue === newVal) return null;
924+
setElementStyles(root, { [cssVar]: newVal });
925+
return scalarChange(stylePath(rootId, cssVar), oldCssValue, newVal);
926+
}
927+
if (oldCssValue === null) return null;
928+
setElementStyles(root, { [cssVar]: null });
929+
return scalarDelete(stylePath(rootId, cssVar), oldCssValue);
930+
}
931+
932+
/**
933+
* Declaration ops require a real `<html>` in the source: fragment inputs get
934+
* a synthetic wrapper that serialize() strips, so a declaration written there
935+
* would silently vanish on save.
936+
*/
937+
function fragmentCompositionErr(parsed: ParsedDocument): CanResult | null {
938+
if (!parsed.wrapped) return null;
939+
return canErr(
940+
"E_FRAGMENT_COMPOSITION",
941+
"Fragment compositions cannot carry variable declarations.",
942+
"data-composition-variables lives on the <html> element — convert the composition to a full HTML document first.",
943+
);
944+
}
945+
946+
function invalidDeclarationErr(): CanResult {
947+
return canErr(
948+
"E_INVALID_ARGS",
949+
"Not a valid variable declaration.",
950+
"Requires id, label, type (string|number|color|boolean|enum|font|image), and a default matching the type; enum also requires options[].",
951+
);
952+
}
953+
954+
// A variable id becomes a CSS custom-property name (`--{id}`), a `data-var-*`
955+
// attribute value, and a CLI `--variables` key. isCompositionVariable only
956+
// checks it is a non-empty string, so the SDK — the last gate before Studio /
957+
// CSS / CLI make those assumptions — enforces a safe identifier shape here.
958+
const VALID_VARIABLE_ID = /^[A-Za-z_][A-Za-z0-9_-]*$/;
959+
960+
function isValidVariableId(id: string): boolean {
961+
return VALID_VARIABLE_ID.test(id);
962+
}
963+
964+
function invalidVariableIdErr(id: string): CanResult {
965+
return canErr(
966+
"E_INVALID_VARIABLE_ID",
967+
`Variable id ${JSON.stringify(id)} is not a valid identifier.`,
968+
"Ids must match /^[A-Za-z_][A-Za-z0-9_-]*$/ — they become CSS custom-property names (--id), data-var-* attribute values, and CLI --variables keys.",
969+
);
970+
}
971+
972+
/**
973+
* Shared can() precondition for declareVariable/updateVariableDeclaration:
974+
* refuse fragment compositions, non-declaration shapes, and malformed ids.
975+
* Returns the CanResult to surface, or null when the declaration is well-formed.
976+
* The shape check runs before the id access so a null/non-object declaration
977+
* yields a CanResult, not a TypeError.
978+
*/
979+
function declarationPreconditionErr(
980+
parsed: ParsedDocument,
981+
declaration: CompositionVariable,
982+
): CanResult | null {
983+
const fragmentErr = fragmentCompositionErr(parsed);
984+
if (fragmentErr) return fragmentErr;
985+
if (!isCompositionVariable(declaration)) return invalidDeclarationErr();
986+
if (!isValidVariableId(declaration.id)) return invalidVariableIdErr(declaration.id);
987+
return null;
988+
}
989+
990+
function handleDeclareVariable(
991+
parsed: ParsedDocument,
992+
declaration: CompositionVariable,
993+
): MutationResult {
994+
// Defensive re-check of can(): never write an invalid or duplicate
995+
// declaration into the schema. Fragment sources have no <html> of their
996+
// own — writing to the synthetic wrapper would be lost on serialize.
997+
if (parsed.wrapped) return EMPTY;
998+
if (!isCompositionVariable(declaration)) return EMPTY;
999+
if (!isValidVariableId(declaration.id)) return EMPTY;
1000+
if (findVariableDeclaration(parsed.document, declaration.id) !== undefined) return EMPTY;
1001+
if (!writeVariableDeclaration(parsed.document, declaration)) return EMPTY;
1002+
const path = variableDeclPath(declaration.id);
1003+
const result: MutationResult = {
1004+
forward: [patchAdd(path, declaration)],
1005+
inverse: [patchRemove(path)],
1006+
};
1007+
// Same CSS compat channel every other variable op maintains — a composition
1008+
// CSS-bound to var(--id) must resolve regardless of which op set the value.
1009+
if (isScalar(declaration.default)) {
1010+
const css = cssCompatChange(parsed, declaration.id, String(declaration.default));
1011+
if (css) {
1012+
result.forward.push(css.forward);
1013+
result.inverse.push(css.inverse);
1014+
}
1015+
}
1016+
return result;
1017+
}
1018+
1019+
function handleUpdateVariableDeclaration(
1020+
parsed: ParsedDocument,
1021+
id: string,
1022+
declaration: CompositionVariable,
1023+
): MutationResult {
1024+
if (parsed.wrapped) return EMPTY;
1025+
if (!isCompositionVariable(declaration) || declaration.id !== id) return EMPTY;
1026+
const old = findVariableDeclaration(parsed.document, id);
1027+
if (old === undefined) return EMPTY;
1028+
writeVariableDeclaration(parsed.document, declaration);
1029+
const p = valueChange(variableDeclPath(id), old, declaration);
1030+
const result: MutationResult = { forward: [p.forward], inverse: [p.inverse] };
1031+
1032+
// Default changed → keep the CSS compat prop in sync (set for scalars,
1033+
// clear when the new default is object-valued font/image), and emit the
1034+
// paired /variables value patch so the T3 override-set's var.{id} entry
1035+
// agrees with the varDecl.{id} snapshot regardless of replay order.
1036+
const oldDefault = old.default;
1037+
const newDefault = declaration.default;
1038+
if (JSON.stringify(oldDefault) !== JSON.stringify(newDefault)) {
1039+
const valueP = valueChange(variablePath(id), oldDefault ?? null, newDefault);
1040+
result.forward.push(valueP.forward);
1041+
result.inverse.push(valueP.inverse);
1042+
const css = cssCompatChange(parsed, id, isScalar(newDefault) ? String(newDefault) : null);
1043+
if (css) {
1044+
result.forward.push(css.forward);
1045+
result.inverse.push(css.inverse);
1046+
}
1047+
}
1048+
return result;
1049+
}
1050+
1051+
function handleRemoveVariableDeclaration(parsed: ParsedDocument, id: string): MutationResult {
1052+
if (parsed.wrapped) return EMPTY;
1053+
const old = findVariableDeclaration(parsed.document, id);
1054+
if (old === undefined) return EMPTY;
1055+
removeVariableDeclarationEntry(parsed.document, id);
1056+
const path = variableDeclPath(id);
1057+
const result: MutationResult = {
1058+
forward: [patchRemove(path)],
1059+
inverse: [patchAdd(path, old)],
1060+
};
1061+
const css = cssCompatChange(parsed, id, null);
1062+
if (css) {
1063+
result.forward.push(css.forward);
1064+
result.inverse.push(css.inverse);
1065+
}
1066+
return result;
1067+
}
1068+
8881069
// ─── GSAP selector helpers ───────────────────────────────────────────────────
8891070

8901071
function selectorMatchesId(selector: string, id: HfId): boolean {
@@ -1494,6 +1675,45 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
14941675
if (findRoot(parsed.document) === null)
14951676
return canErr("E_NO_ROOT", "Composition root element not found.");
14961677
return CAN_OK;
1678+
case "declareVariable": {
1679+
const preErr = declarationPreconditionErr(parsed, op.declaration);
1680+
if (preErr) return preErr;
1681+
if (findVariableDeclaration(parsed.document, op.declaration.id) !== undefined)
1682+
return canErr(
1683+
"E_DUPLICATE_VARIABLE",
1684+
`Variable "${op.declaration.id}" is already declared.`,
1685+
"Use updateVariableDeclaration to change it, or setVariableValue to change its default.",
1686+
);
1687+
return CAN_OK;
1688+
}
1689+
case "updateVariableDeclaration": {
1690+
const preErr = declarationPreconditionErr(parsed, op.declaration);
1691+
if (preErr) return preErr;
1692+
if (op.declaration.id !== op.id)
1693+
return canErr(
1694+
"E_INVALID_ARGS",
1695+
`declaration.id ("${op.declaration.id}") must match id ("${op.id}").`,
1696+
"Variable ids are immutable — rename via removeVariableDeclaration + declareVariable.",
1697+
);
1698+
if (findVariableDeclaration(parsed.document, op.id) === undefined)
1699+
return canErr(
1700+
"E_VARIABLE_NOT_FOUND",
1701+
`Variable "${op.id}" is not declared.`,
1702+
"Check comp.getVariableDeclarations(), or add it with declareVariable.",
1703+
);
1704+
return CAN_OK;
1705+
}
1706+
case "removeVariableDeclaration": {
1707+
const fragmentErr = fragmentCompositionErr(parsed);
1708+
if (fragmentErr) return fragmentErr;
1709+
if (findVariableDeclaration(parsed.document, op.id) === undefined)
1710+
return canErr(
1711+
"E_VARIABLE_NOT_FOUND",
1712+
`Variable "${op.id}" is not declared.`,
1713+
"Check comp.getVariableDeclarations().",
1714+
);
1715+
return CAN_OK;
1716+
}
14971717
case "setCompositionMetadata":
14981718
case "setClassStyle":
14991719
return CAN_OK;

0 commit comments

Comments
 (0)