Skip to content

Commit cab45ce

Browse files
committed
feat(sdk,core): phase 3b — 8 gsap/label ops + setClassStyle
1 parent 0bab944 commit cab45ce

12 files changed

Lines changed: 1073 additions & 36 deletions

File tree

packages/core/package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@
7474
"import": "./src/parsers/gsapParser.ts",
7575
"types": "./src/parsers/gsapParser.ts"
7676
},
77+
"./gsap-parser-acorn": {
78+
"import": "./src/parsers/gsapParserAcorn.ts",
79+
"types": "./src/parsers/gsapParserAcorn.ts"
80+
},
81+
"./gsap-writer-acorn": {
82+
"import": "./src/parsers/gsapWriterAcorn.ts",
83+
"types": "./src/parsers/gsapWriterAcorn.ts"
84+
},
7785
"./gsap-constants": {
7886
"import": "./src/parsers/gsapConstants.ts",
7987
"types": "./src/parsers/gsapConstants.ts"
@@ -153,6 +161,14 @@
153161
"import": "./dist/parsers/gsapParser.js",
154162
"types": "./dist/parsers/gsapParser.d.ts"
155163
},
164+
"./gsap-parser-acorn": {
165+
"import": "./dist/parsers/gsapParserAcorn.js",
166+
"types": "./dist/parsers/gsapParserAcorn.d.ts"
167+
},
168+
"./gsap-writer-acorn": {
169+
"import": "./dist/parsers/gsapWriterAcorn.js",
170+
"types": "./dist/parsers/gsapWriterAcorn.d.ts"
171+
},
156172
"./gsap-constants": {
157173
"import": "./dist/parsers/gsapConstants.js",
158174
"types": "./dist/parsers/gsapConstants.d.ts"

packages/core/src/parsers/gsapWriterAcorn.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,12 @@ export function updateAnimationInScript(
179179
}
180180
}
181181

182+
if (updates.extras) {
183+
for (const [key, value] of Object.entries(updates.extras)) {
184+
upsertProp(ms, call.varsArg, key, value as number | string);
185+
}
186+
}
187+
182188
return ms.toString();
183189
}
184190

@@ -366,3 +372,58 @@ export function removeKeyframeFromScript(
366372
removeProp(ms, match.prop, allProps);
367373
return ms.toString();
368374
}
375+
376+
// ── Label write ops ───────────────────────────────────────────────────────────
377+
378+
export function addLabelToScript(script: string, name: string, position: number): string {
379+
const parsed = parseGsapScriptAcornForWrite(script);
380+
if (!parsed) return script;
381+
382+
const ms = new MagicString(script);
383+
const labelCode = `${parsed.timelineVar}.addLabel(${JSON.stringify(name)}, ${valueToCode(position)});`;
384+
385+
let insertionPoint: number;
386+
if (parsed.located.length > 0) {
387+
const lastCall = parsed.located[parsed.located.length - 1]!.call;
388+
const exprStmt = findEnclosingExpressionStatement(lastCall.ancestors);
389+
insertionPoint = exprStmt?.end ?? lastCall.node.end;
390+
} else {
391+
const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
392+
insertionPoint = tlDecl?.end ?? script.length;
393+
}
394+
395+
ms.appendLeft(insertionPoint, "\n" + labelCode);
396+
return ms.toString();
397+
}
398+
399+
export function removeLabelFromScript(script: string, name: string): string {
400+
const parsed = parseGsapScriptAcornForWrite(script);
401+
if (!parsed) return script;
402+
403+
let target: any = null;
404+
acornWalk.simple(parsed.ast, {
405+
// fallow-ignore-next-line complexity
406+
ExpressionStatement(node: any) {
407+
if (target) return;
408+
const expr = node.expression;
409+
if (
410+
expr?.type === "CallExpression" &&
411+
expr.callee?.type === "MemberExpression" &&
412+
expr.callee.object?.name === parsed.timelineVar &&
413+
expr.callee.property?.name === "addLabel" &&
414+
expr.arguments?.[0]?.type === "Literal" &&
415+
expr.arguments[0].value === name
416+
) {
417+
target = node;
418+
}
419+
},
420+
});
421+
422+
if (!target) return script;
423+
424+
const ms = new MagicString(script);
425+
const end =
426+
target.end < script.length && script[target.end] === "\n" ? target.end + 1 : target.end;
427+
ms.remove(target.start, end);
428+
return ms.toString();
429+
}

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

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,36 @@
1010

1111
import type { JsonPatchOp, OverrideSet } from "../types.js";
1212
import type { ParsedDocument } from "./model.js";
13-
import { findById, findRoot, setElementStyles, setOwnText } from "./model.js";
13+
import {
14+
findById,
15+
findRoot,
16+
setElementStyles,
17+
setOwnText,
18+
setGsapScript,
19+
setStyleSheet,
20+
} from "./model.js";
1421
import { keyToPath } from "./patches.js";
1522

1623
// ─── Path parser ────────────────────────────────────────────────────────────
1724

1825
interface ParsedPath {
19-
type: "style" | "text" | "attribute" | "timing" | "hold" | "element" | "variable" | "metadata";
26+
type:
27+
| "style"
28+
| "text"
29+
| "attribute"
30+
| "timing"
31+
| "hold"
32+
| "element"
33+
| "variable"
34+
| "metadata"
35+
| "script"
36+
| "stylesheet";
2037
id?: string;
2138
prop?: string;
2239
field?: string;
2340
}
2441

42+
// fallow-ignore-next-line complexity
2543
function parsePath(path: string): ParsedPath | null {
2644
const styleM = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path);
2745
if (styleM) return { type: "style", id: styleM[1], prop: styleM[2] };
@@ -52,6 +70,9 @@ function parsePath(path: string): ParsedPath | null {
5270
const metaM = /^\/metadata\/(.+)$/.exec(path);
5371
if (metaM) return { type: "metadata", field: metaM[1] };
5472

73+
if (path === "/script/gsap") return { type: "script" };
74+
if (path === "/style/css") return { type: "stylesheet" };
75+
5576
return null;
5677
}
5778

@@ -185,6 +206,22 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo
185206
break;
186207
}
187208

209+
case "script": {
210+
if (patch.op !== "remove") {
211+
setGsapScript(parsed.document, String(patch.value ?? ""));
212+
}
213+
break;
214+
}
215+
216+
case "stylesheet": {
217+
if (patch.op === "remove") {
218+
setStyleSheet(parsed.document, "");
219+
} else {
220+
setStyleSheet(parsed.document, String(patch.value ?? ""));
221+
}
222+
break;
223+
}
224+
188225
case "metadata": {
189226
const root = findRoot(parsed.document);
190227
if (!root || !p.field) return;
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* Hand-rolled flat-CSS rule editor.
3+
*
4+
* Handles only simple flat rules (`selector { declarations }`) — no nesting,
5+
* no @-rules, no CSS comments. Sufficient for composition <style> blocks
6+
* generated by hyperframes, which never contain those constructs.
7+
*/
8+
9+
// ─── Types ────────────────────────────────────────────────────────────────────
10+
11+
interface CssRule {
12+
selector: string;
13+
body: string;
14+
/** Byte offset of the first char of the selector. */
15+
start: number;
16+
/** Byte offset after the closing `}`. */
17+
end: number;
18+
}
19+
20+
// ─── Parsing ──────────────────────────────────────────────────────────────────
21+
22+
function parseCssRules(css: string): CssRule[] {
23+
const rules: CssRule[] = [];
24+
const re = /([^{}]+?)\s*\{([^}]*)\}/g;
25+
let m: RegExpExecArray | null;
26+
while ((m = re.exec(css)) !== null) {
27+
rules.push({
28+
selector: m[1]!.trim(),
29+
body: m[2]!,
30+
start: m.index,
31+
end: m.index + m[0].length,
32+
});
33+
}
34+
return rules;
35+
}
36+
37+
function parseDeclarations(body: string): Record<string, string> {
38+
const decls: Record<string, string> = {};
39+
for (const part of body.split(";")) {
40+
const colon = part.indexOf(":");
41+
if (colon === -1) continue;
42+
const prop = part.slice(0, colon).trim();
43+
const value = part.slice(colon + 1).trim();
44+
if (prop && value) decls[prop] = value;
45+
}
46+
return decls;
47+
}
48+
49+
function serializeDeclarations(decls: Record<string, string>): string {
50+
const entries = Object.entries(decls);
51+
if (!entries.length) return "";
52+
return " " + entries.map(([k, v]) => `${k}: ${v}`).join("; ") + "; ";
53+
}
54+
55+
function normalizeSelector(sel: string): string {
56+
return sel.trim().replace(/\s+/g, " ");
57+
}
58+
59+
// ─── Public API ───────────────────────────────────────────────────────────────
60+
61+
/**
62+
* Update or insert a CSS rule.
63+
*
64+
* Finds the first rule whose selector matches (after whitespace normalization)
65+
* and merges the given declarations into it. A null value removes the property.
66+
* If no matching rule exists, appends a new one.
67+
*
68+
* Returns the modified CSS string. Returns `css` unchanged when nothing changed.
69+
*/
70+
export function upsertCssRule(
71+
css: string,
72+
selector: string,
73+
styles: Record<string, string | null>,
74+
): string {
75+
const normalized = normalizeSelector(selector);
76+
const rules = parseCssRules(css);
77+
const idx = rules.findIndex((r) => normalizeSelector(r.selector) === normalized);
78+
79+
if (idx !== -1) {
80+
const rule = rules[idx]!;
81+
const decls = parseDeclarations(rule.body);
82+
for (const [prop, value] of Object.entries(styles)) {
83+
if (value === null) {
84+
delete decls[prop];
85+
} else {
86+
decls[prop] = value;
87+
}
88+
}
89+
const newRuleText = `${rule.selector} {${serializeDeclarations(decls)}}`;
90+
return css.slice(0, rule.start) + newRuleText + css.slice(rule.end);
91+
}
92+
93+
// Append new rule — skip if all values are null (nothing to write).
94+
const newDecls: Record<string, string> = {};
95+
for (const [prop, value] of Object.entries(styles)) {
96+
if (value !== null) newDecls[prop] = value;
97+
}
98+
if (!Object.keys(newDecls).length) return css;
99+
100+
const newRuleText = `${selector} {${serializeDeclarations(newDecls)}}`;
101+
const sep = css.length > 0 && !css.endsWith("\n") ? "\n" : "";
102+
return css + sep + newRuleText + "\n";
103+
}

packages/sdk/src/engine/model.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,50 @@ export function setOwnText(el: Element, text: string): void {
132132
}
133133
}
134134

135+
// ─── CSS style helpers ────────────────────────────────────────────────────────
136+
137+
function findStyleElement(document: Document): Element | null {
138+
return document.querySelector("style") as unknown as Element | null;
139+
}
140+
141+
export function getStyleSheet(document: Document): string {
142+
return findStyleElement(document)?.textContent ?? "";
143+
}
144+
145+
export function setStyleSheet(document: Document, css: string): void {
146+
let el = findStyleElement(document);
147+
if (!el) {
148+
el = document.createElement("style") as unknown as Element;
149+
const head =
150+
(document.querySelector("head") as unknown as Element | null) ??
151+
(document.body as unknown as Element);
152+
(head as any).appendChild(el);
153+
}
154+
el.textContent = css;
155+
}
156+
157+
// ─── GSAP script helpers ──────────────────────────────────────────────────────
158+
159+
function findGsapScriptElement(document: Document): Element | null {
160+
const scripts = document.querySelectorAll("script");
161+
for (const script of Array.from(scripts)) {
162+
const text = script.textContent ?? "";
163+
if (text.includes("gsap") || text.includes("ScrollTrigger"))
164+
return script as unknown as Element;
165+
}
166+
return null;
167+
}
168+
169+
export function getGsapScript(document: Document): string | null {
170+
const el = findGsapScriptElement(document);
171+
return el ? (el.textContent ?? "") : null;
172+
}
173+
174+
export function setGsapScript(document: Document, newScript: string): void {
175+
const el = findGsapScriptElement(document);
176+
if (el) el.textContent = newScript;
177+
}
178+
135179
// ─── Sibling index ────────────────────────────────────────────────────────────
136180

137181
export function getSiblingIndex(el: Element): number {

0 commit comments

Comments
 (0)