@lumencast/compiler 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/compile.ts CHANGED
@@ -1,325 +1,361 @@
1
- // LSML 1.0 / 1.1 → flat RenderBundle compiler.
2
- //
3
- // LSML lets authors write idiomatic primitives with inline `bind: { value: "path" }`,
4
- // CSS-style `style.fontSize`, `repeat.template`, `animate` directives. The runtime
5
- // expects a flat shape: per-node `bindings` map, primitive-specific prop names
6
- // (Solar lineage: `text.size`, `text.colour`), and `repeat` whose template is its
7
- // only child.
8
- //
9
- // This compiler bridges the two formats. It does NOT execute the bundle — it
10
- // produces a JSON the runtime then renders.
11
- //
12
- // Version support :
13
- // - 1.0 (LSML-1.md) : the original 9-primitive catalog.
14
- // - 1.1 (LSML-1.md §17 / §5.4 / §4.9) : additive over 1.0 — `instance`
15
- // primitive, universal props (`visible` / `sizing` / `opacity` /
16
- // `rotation`) on every primitive, `bindUniversal` field, multi-fill
17
- // `fills[]` on shapes, stacked `backgrounds[]` on frames, profile
18
- // declarations, `$schema` field.
19
- //
20
- // Unsupported 1.1 features compile to best-effort output (the renderer
21
- // surfaces `BUNDLE_INCOMPATIBLE` per LSML §15.1 if it can't honour them).
22
- // Bundles tagged 2.x are rejected — major bumps require explicit support.
23
-
24
- import type { RenderBundle, RenderNode } from "@lumencast/runtime";
25
- import type {
26
- LSMLAnimateDirective,
27
- LSMLBundle,
28
- LSMLNode,
29
- LSMLRepeat,
30
- LSMLText,
31
- } from "./lsml-types.js";
32
-
33
- export interface CompileOptions {
34
- /** When true, throws on any unrecognized LSML extension. Default false (warn-only). */
35
- strict?: boolean;
36
- /** Optional warn collector — receives each warning string. */
37
- onWarn?: (message: string) => void;
38
- }
39
-
40
- const SUPPORTED_VERSIONS = new Set(["1.0", "1.1"] as const);
41
-
42
- export function compileBundle(lsml: LSMLBundle, options: CompileOptions = {}): RenderBundle {
43
- if (!SUPPORTED_VERSIONS.has(lsml.lsml as "1.0" | "1.1")) {
44
- throw new Error(
45
- `compiler: LSML version "${lsml.lsml}" is not supported (supported: ${[...SUPPORTED_VERSIONS].join(", ")})`,
46
- );
47
- }
48
- return {
49
- scene_version: lsml.scene_version,
50
- root: compileNode(lsml.layout, options),
51
- ...(lsml.operator_inputs
52
- ? {
53
- operator_inputs: lsml.operator_inputs.map((oi) => ({
54
- path: oi.path,
55
- label: oi.label,
56
- type: oi.type as never,
57
- writable_by: oi.writable_by,
58
- ...(oi.group !== undefined ? { group: oi.group } : {}),
59
- ...(oi.constraints ?? {}),
60
- })),
61
- }
62
- : {}),
63
- ...(lsml.external_adapters
64
- ? {
65
- external_adapters: lsml.external_adapters as RenderBundle["external_adapters"],
66
- }
67
- : {}),
68
- };
69
- }
70
-
71
- function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
72
- if (node.kind === "repeat") {
73
- return compileRepeat(node, opts);
74
- }
75
-
76
- const props: Record<string, unknown> = {};
77
- const bindings: Record<string, string> = {};
78
-
79
- // Common: bind.value/src → bindings
80
- if (node.bind?.value !== undefined) bindings["value"] = node.bind.value;
81
- if (node.bind?.src !== undefined) bindings["src"] = node.bind.src;
82
- if (node.bindStyle) {
83
- for (const [k, v] of Object.entries(node.bindStyle)) bindings[k] = v;
84
- }
85
-
86
- switch (node.kind) {
87
- case "stack":
88
- if (node.direction !== undefined) props["direction"] = node.direction;
89
- if (node.gap !== undefined) props["gap"] = node.gap;
90
- if (node.align !== undefined) props["align"] = mapAlign(node.align);
91
- if (node.justify !== undefined) props["justify"] = mapJustify(node.justify);
92
- if (node.padding !== undefined) props["padding"] = node.padding;
93
- if (node.rtl !== undefined) props["rtl"] = node.rtl;
94
- break;
95
-
96
- case "grid":
97
- if (node.columns !== undefined) props["columns"] = node.columns;
98
- if (node.rows !== undefined) props["rows"] = node.rows;
99
- if (node.gap !== undefined) props["gap"] = node.gap;
100
- if (node.padding !== undefined) props["padding"] = node.padding;
101
- break;
102
-
103
- case "frame":
104
- if (node.size !== undefined) {
105
- props["width"] = node.size.w;
106
- props["height"] = node.size.h;
107
- }
108
- if (node.position !== undefined) {
109
- props["x"] = node.position.x;
110
- props["y"] = node.position.y;
111
- }
112
- if (node.background !== undefined) props["background"] = node.background;
113
- break;
114
-
115
- case "text":
116
- mapTextStyle(node, props);
117
- if (node.format !== undefined) props["format"] = node.format;
118
- if (node.maxLines !== undefined) props["maxLines"] = node.maxLines;
119
- break;
120
-
121
- case "image":
122
- props["alt"] = node.alt;
123
- props["width"] = node.size.w;
124
- props["height"] = node.size.h;
125
- if (node.fit !== undefined) props["fit"] = node.fit;
126
- break;
127
-
128
- case "shape":
129
- props["geometry"] = node.geometry;
130
- if (node.size !== undefined) {
131
- props["width"] = node.size.w;
132
- props["height"] = node.size.h;
133
- }
134
- if (node.pathData !== undefined) props["pathData"] = node.pathData;
135
- if (node.fill !== undefined) props["fill"] = node.fill;
136
- if (node.stroke !== undefined) props["stroke"] = node.stroke;
137
- if (node.cornerRadius !== undefined) props["cornerRadius"] = node.cornerRadius;
138
- if (node.ariaLabel !== undefined) props["ariaLabel"] = node.ariaLabel;
139
- break;
140
-
141
- case "media":
142
- props["kind_hint"] = node.kind_hint;
143
- if (node.controls !== undefined) props["controls"] = node.controls;
144
- if (node.autoplay !== undefined) props["autoplay"] = node.autoplay;
145
- if (node.muted !== undefined) props["muted"] = node.muted;
146
- if (node.loop !== undefined) props["loop"] = node.loop;
147
- if (node.size !== undefined) {
148
- props["width"] = node.size.w;
149
- props["height"] = node.size.h;
150
- }
151
- break;
152
-
153
- case "instance":
154
- // 1.1+ — sub-scene mount (LSML §4.9). The runtime resolves
155
- // `scene_id` + `scene_version` to a separate bundle and renders
156
- // it inline ; the compiler just forwards the reference.
157
- props["scene_id"] = node.scene_id;
158
- props["scene_version"] = node.scene_version;
159
- if (node.size !== undefined) {
160
- props["width"] = node.size.w;
161
- props["height"] = node.size.h;
162
- }
163
- if (node.fit !== undefined) props["fit"] = node.fit;
164
- if (node.params !== undefined) props["params"] = node.params;
165
- if (node.bindParams) {
166
- for (const [k, v] of Object.entries(node.bindParams)) {
167
- bindings[`params.${k}`] = v;
168
- }
169
- }
170
- break;
171
- }
172
-
173
- // Universal props (LSML §5.4 — 1.1+). Forwarded to the renderer when
174
- // present on the source node. Defaults are spec-side, not compiler-side
175
- // (the runtime applies them per primitive).
176
- if (node.visible !== undefined) props["visible"] = node.visible;
177
- if (node.opacity !== undefined) props["opacity"] = node.opacity;
178
- if (node.rotation !== undefined) props["rotation"] = node.rotation;
179
- if (node.sizing !== undefined) props["sizing"] = node.sizing;
180
- if (node.position !== undefined && props["x"] === undefined && props["y"] === undefined) {
181
- // Frame's case above already sets x/y from `position` ; the universal
182
- // §5.4 prop takes effect on every other primitive.
183
- props["x"] = node.position.x;
184
- props["y"] = node.position.y;
185
- }
186
- if (node.bindUniversal) {
187
- for (const [k, v] of Object.entries(node.bindUniversal)) bindings[k] = v;
188
- }
189
-
190
- const children = node.children?.map((c) => compileNode(c, opts));
191
-
192
- const out: RenderNode = { kind: node.kind };
193
- if (node.id !== undefined) out.id = node.id;
194
- if (Object.keys(props).length > 0) out.props = props;
195
- if (Object.keys(bindings).length > 0) out.bindings = bindings;
196
- if (children && children.length > 0) out.children = children;
197
-
198
- // Animate directive → transitions on the listed prop keys.
199
- if (node.animate) {
200
- const tx = compileAnimate(node.animate);
201
- if (tx) {
202
- const transitions: Record<string, ReturnType<typeof compileAnimate>> = {};
203
- if (node.animate.opacity !== undefined) transitions["opacity"] = tx;
204
- if (node.animate.transform?.scale !== undefined) transitions["scale"] = tx;
205
- if (node.animate.transform?.rotate !== undefined) transitions["rotate"] = tx;
206
- if (node.animate.transform?.translate !== undefined) {
207
- transitions["x"] = tx;
208
- transitions["y"] = tx;
209
- }
210
- // Type assertion: RenderNode.transitions matches Transition shape; the
211
- // cast keeps the compiler self-contained without re-importing the runtime
212
- // Transition type.
213
- if (Object.keys(transitions).length > 0) {
214
- out.transitions = transitions as RenderNode["transitions"];
215
- }
216
- }
217
- }
218
-
219
- return out;
220
- }
221
-
222
- function compileRepeat(node: LSMLRepeat, opts: CompileOptions): RenderNode {
223
- if (!node.bind?.items) {
224
- throw new Error(`compiler: repeat node "${node.id ?? "<anon>"}" missing bind.items`);
225
- }
226
- const compiledTemplate = compileNode(node.template, opts);
227
- const out: RenderNode = {
228
- kind: "repeat",
229
- bindings: { items: node.bind.items },
230
- children: [compiledTemplate],
231
- };
232
- if (node.id !== undefined) out.id = node.id;
233
- return out;
234
- }
235
-
236
- function mapTextStyle(node: LSMLText, props: Record<string, unknown>): void {
237
- if (!node.style) return;
238
- const s = node.style;
239
- // Solar's Text primitive consumes size/weight/colour (UK), not CSS-style names.
240
- if (s.fontSize !== undefined) props["size"] = s.fontSize;
241
- if (s.fontWeight !== undefined) props["weight"] = s.fontWeight;
242
- if (s.color !== undefined) props["colour"] = s.color;
243
- if (s.textAlign !== undefined) props["align"] = mapTextAlign(s.textAlign);
244
- if (s.lineHeight !== undefined) props["lineHeight"] = s.lineHeight;
245
- }
246
-
247
- function mapAlign(a: NonNullable<Extract<LSMLNode, { kind: "stack" }>["align"]>): string {
248
- // LSML uses CSS-grid vocabulary; Solar's Stack consumes flexbox vocabulary.
249
- switch (a) {
250
- case "start":
251
- return "flex-start";
252
- case "center":
253
- return "center";
254
- case "end":
255
- return "flex-end";
256
- case "stretch":
257
- return "stretch";
258
- }
259
- }
260
-
261
- function mapJustify(j: NonNullable<Extract<LSMLNode, { kind: "stack" }>["justify"]>): string {
262
- switch (j) {
263
- case "start":
264
- return "flex-start";
265
- case "center":
266
- return "center";
267
- case "end":
268
- return "flex-end";
269
- case "space-between":
270
- return "space-between";
271
- case "space-around":
272
- return "space-around";
273
- }
274
- }
275
-
276
- function mapTextAlign(a: NonNullable<NonNullable<LSMLText["style"]>["textAlign"]>): string {
277
- switch (a) {
278
- case "start":
279
- return "left";
280
- case "end":
281
- return "right";
282
- default:
283
- return a;
284
- }
285
- }
286
-
287
- function compileAnimate(a: LSMLAnimateDirective):
288
- | {
289
- kind: "tween";
290
- duration_ms: number;
291
- ease?: "linear" | "cubic-in" | "cubic-out" | "cubic-in-out";
292
- }
293
- | { kind: "spring"; stiffness?: number; damping?: number }
294
- | undefined {
295
- const t = a.transition;
296
- if (!t) return undefined;
297
- if (t.easing === "spring") {
298
- const out: { kind: "spring"; stiffness?: number; damping?: number } = { kind: "spring" };
299
- if (t.stiffness !== undefined) out.stiffness = t.stiffness;
300
- if (t.damping !== undefined) out.damping = t.damping;
301
- return out;
302
- }
303
- return {
304
- kind: "tween",
305
- duration_ms: t.duration ?? 200,
306
- ease: mapEase(t.easing),
307
- };
308
- }
309
-
310
- function mapEase(
311
- e: "linear" | "ease-in" | "ease-out" | "ease-in-out" | "spring" | undefined,
312
- ): "linear" | "cubic-in" | "cubic-out" | "cubic-in-out" | undefined {
313
- switch (e) {
314
- case "linear":
315
- return "linear";
316
- case "ease-in":
317
- return "cubic-in";
318
- case "ease-out":
319
- return "cubic-out";
320
- case "ease-in-out":
321
- return "cubic-in-out";
322
- default:
323
- return undefined;
324
- }
325
- }
1
+ // LSML 1.0 / 1.1 → flat RenderBundle compiler.
2
+ //
3
+ // LSML lets authors write idiomatic primitives with inline `bind: { value: "path" }`,
4
+ // CSS-style `style.fontSize`, `repeat.template`, `animate` directives. The runtime
5
+ // expects a flat shape: per-node `bindings` map, primitive-specific prop names
6
+ // (Solar lineage: `text.size`, `text.colour`), and `repeat` whose template is its
7
+ // only child.
8
+ //
9
+ // This compiler bridges the two formats. It does NOT execute the bundle — it
10
+ // produces a JSON the runtime then renders.
11
+ //
12
+ // Version support :
13
+ // - 1.0 (LSML-1.md) : the original 9-primitive catalog.
14
+ // - 1.1 (LSML-1.md §17 / §5.4 / §4.9) : additive over 1.0 — `instance`
15
+ // primitive, universal props (`visible` / `sizing` / `opacity` /
16
+ // `rotation`) on every primitive, `bindUniversal` field, multi-fill
17
+ // `fills[]` on shapes, stacked `backgrounds[]` on frames, profile
18
+ // declarations, `$schema` field.
19
+ //
20
+ // Unsupported 1.1 features compile to best-effort output (the renderer
21
+ // surfaces `BUNDLE_INCOMPATIBLE` per LSML §15.1 if it can't honour them).
22
+ // Bundles tagged 2.x are rejected — major bumps require explicit support.
23
+
24
+ import type { RenderBundle, RenderNode } from "@lumencast/runtime";
25
+ import type {
26
+ LSMLAnimateDirective,
27
+ LSMLAnimateState,
28
+ LSMLBundle,
29
+ LSMLNode,
30
+ LSMLRepeat,
31
+ LSMLText,
32
+ } from "./lsml-types.js";
33
+
34
+ export interface CompileOptions {
35
+ /** When true, throws on any unrecognized LSML extension. Default false (warn-only). */
36
+ strict?: boolean;
37
+ /** Optional warn collector — receives each warning string. */
38
+ onWarn?: (message: string) => void;
39
+ }
40
+
41
+ const SUPPORTED_VERSIONS = new Set(["1.0", "1.1"] as const);
42
+
43
+ export function compileBundle(lsml: LSMLBundle, options: CompileOptions = {}): RenderBundle {
44
+ if (!SUPPORTED_VERSIONS.has(lsml.lsml as "1.0" | "1.1")) {
45
+ throw new Error(
46
+ `compiler: LSML version "${lsml.lsml}" is not supported (supported: ${[...SUPPORTED_VERSIONS].join(", ")})`,
47
+ );
48
+ }
49
+ return {
50
+ scene_version: lsml.scene_version,
51
+ root: compileNode(lsml.layout, options),
52
+ ...(lsml.operator_inputs
53
+ ? {
54
+ operator_inputs: lsml.operator_inputs.map((oi) => ({
55
+ path: oi.path,
56
+ label: oi.label,
57
+ type: oi.type as never,
58
+ writable_by: oi.writable_by,
59
+ ...(oi.group !== undefined ? { group: oi.group } : {}),
60
+ ...(oi.constraints ?? {}),
61
+ })),
62
+ }
63
+ : {}),
64
+ ...(lsml.external_adapters
65
+ ? {
66
+ external_adapters: lsml.external_adapters as RenderBundle["external_adapters"],
67
+ }
68
+ : {}),
69
+ };
70
+ }
71
+
72
+ function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
73
+ if (node.kind === "repeat") {
74
+ return compileRepeat(node, opts);
75
+ }
76
+
77
+ const props: Record<string, unknown> = {};
78
+ const bindings: Record<string, string> = {};
79
+
80
+ // Common: bind.value/src bindings
81
+ if (node.bind?.value !== undefined) bindings["value"] = node.bind.value;
82
+ if (node.bind?.src !== undefined) bindings["src"] = node.bind.src;
83
+ if (node.bindStyle) {
84
+ for (const [k, v] of Object.entries(node.bindStyle)) bindings[k] = v;
85
+ }
86
+
87
+ switch (node.kind) {
88
+ case "stack":
89
+ if (node.direction !== undefined) props["direction"] = node.direction;
90
+ if (node.gap !== undefined) props["gap"] = node.gap;
91
+ if (node.align !== undefined) props["align"] = mapAlign(node.align);
92
+ if (node.justify !== undefined) props["justify"] = mapJustify(node.justify);
93
+ if (node.padding !== undefined) props["padding"] = node.padding;
94
+ if (node.rtl !== undefined) props["rtl"] = node.rtl;
95
+ break;
96
+
97
+ case "grid":
98
+ if (node.columns !== undefined) props["columns"] = node.columns;
99
+ if (node.rows !== undefined) props["rows"] = node.rows;
100
+ if (node.gap !== undefined) props["gap"] = node.gap;
101
+ if (node.padding !== undefined) props["padding"] = node.padding;
102
+ break;
103
+
104
+ case "frame":
105
+ if (node.size !== undefined) {
106
+ props["width"] = node.size.w;
107
+ props["height"] = node.size.h;
108
+ }
109
+ if (node.position !== undefined) {
110
+ props["x"] = node.position.x;
111
+ props["y"] = node.position.y;
112
+ }
113
+ if (node.background !== undefined) props["background"] = node.background;
114
+ break;
115
+
116
+ case "text":
117
+ mapTextStyle(node, props);
118
+ if (node.format !== undefined) props["format"] = node.format;
119
+ if (node.maxLines !== undefined) props["maxLines"] = node.maxLines;
120
+ break;
121
+
122
+ case "image":
123
+ props["alt"] = node.alt;
124
+ props["width"] = node.size.w;
125
+ props["height"] = node.size.h;
126
+ if (node.fit !== undefined) props["fit"] = node.fit;
127
+ break;
128
+
129
+ case "shape":
130
+ props["geometry"] = node.geometry;
131
+ if (node.size !== undefined) {
132
+ props["width"] = node.size.w;
133
+ props["height"] = node.size.h;
134
+ }
135
+ if (node.pathData !== undefined) props["pathData"] = node.pathData;
136
+ if (node.fill !== undefined) props["fill"] = node.fill;
137
+ if (node.stroke !== undefined) props["stroke"] = node.stroke;
138
+ if (node.cornerRadius !== undefined) props["cornerRadius"] = node.cornerRadius;
139
+ if (node.ariaLabel !== undefined) props["ariaLabel"] = node.ariaLabel;
140
+ break;
141
+
142
+ case "media":
143
+ props["kind_hint"] = node.kind_hint;
144
+ if (node.controls !== undefined) props["controls"] = node.controls;
145
+ if (node.autoplay !== undefined) props["autoplay"] = node.autoplay;
146
+ if (node.muted !== undefined) props["muted"] = node.muted;
147
+ if (node.loop !== undefined) props["loop"] = node.loop;
148
+ if (node.size !== undefined) {
149
+ props["width"] = node.size.w;
150
+ props["height"] = node.size.h;
151
+ }
152
+ break;
153
+
154
+ case "instance":
155
+ // 1.1+ sub-scene mount (LSML §4.9). The runtime resolves
156
+ // `scene_id` + `scene_version` to a separate bundle and renders
157
+ // it inline ; the compiler just forwards the reference.
158
+ props["scene_id"] = node.scene_id;
159
+ props["scene_version"] = node.scene_version;
160
+ if (node.size !== undefined) {
161
+ props["width"] = node.size.w;
162
+ props["height"] = node.size.h;
163
+ }
164
+ if (node.fit !== undefined) props["fit"] = node.fit;
165
+ if (node.params !== undefined) props["params"] = node.params;
166
+ if (node.bindParams) {
167
+ for (const [k, v] of Object.entries(node.bindParams)) {
168
+ bindings[`params.${k}`] = v;
169
+ }
170
+ }
171
+ break;
172
+ }
173
+
174
+ // Universal props (LSML §5.4 — 1.1+). Forwarded to the renderer when
175
+ // present on the source node. Defaults are spec-side, not compiler-side
176
+ // (the runtime applies them per primitive).
177
+ if (node.visible !== undefined) props["visible"] = node.visible;
178
+ if (node.opacity !== undefined) props["opacity"] = node.opacity;
179
+ if (node.rotation !== undefined) props["rotation"] = node.rotation;
180
+ if (node.sizing !== undefined) props["sizing"] = node.sizing;
181
+ if (node.position !== undefined && props["x"] === undefined && props["y"] === undefined) {
182
+ // Frame's case above already sets x/y from `position` ; the universal
183
+ // §5.4 prop takes effect on every other primitive.
184
+ props["x"] = node.position.x;
185
+ props["y"] = node.position.y;
186
+ }
187
+ if (node.bindUniversal) {
188
+ for (const [k, v] of Object.entries(node.bindUniversal)) bindings[k] = v;
189
+ }
190
+
191
+ const children = node.children?.map((c) => compileNode(c, opts));
192
+
193
+ const out: RenderNode = { kind: node.kind };
194
+ if (node.id !== undefined) out.id = node.id;
195
+ if (Object.keys(props).length > 0) out.props = props;
196
+ if (Object.keys(bindings).length > 0) out.bindings = bindings;
197
+ if (children && children.length > 0) out.children = children;
198
+
199
+ // Animate directive → transitions on the listed prop keys.
200
+ if (node.animate) {
201
+ const tx = compileAnimate(node.animate);
202
+ if (tx) {
203
+ const transitions: Record<string, ReturnType<typeof compileAnimate>> = {};
204
+ if (node.animate.opacity !== undefined) transitions["opacity"] = tx;
205
+ if (node.animate.transform?.scale !== undefined) transitions["scale"] = tx;
206
+ if (node.animate.transform?.rotate !== undefined) transitions["rotate"] = tx;
207
+ if (node.animate.transform?.translate !== undefined) {
208
+ transitions["x"] = tx;
209
+ transitions["y"] = tx;
210
+ }
211
+ // Type assertion: RenderNode.transitions matches Transition shape; the
212
+ // cast keeps the compiler self-contained without re-importing the runtime
213
+ // Transition type.
214
+ if (Object.keys(transitions).length > 0) {
215
+ out.transitions = transitions as RenderNode["transitions"];
216
+ }
217
+ }
218
+
219
+ // LSML 1.1 §6 `animate.from` → flat framer `initial` map. Lowered
220
+ // independently of `transition` : an author may declare a `from`
221
+ // without a `transition` (mount-play with the runtime's default
222
+ // timing). When no `from` is present, `animate_initial` is omitted
223
+ // and the prior no-mount-play behaviour is preserved (rétro-compat).
224
+ if (node.animate.from) {
225
+ const initial = lowerAnimateState(node.animate.from);
226
+ if (Object.keys(initial).length > 0) {
227
+ out.animate_initial = initial;
228
+ }
229
+ }
230
+ }
231
+
232
+ return out;
233
+ }
234
+
235
+ /** Lower an `animate.from` (or any LSML animate state) into the flat
236
+ * framer-motion key space the runtime primitives consume: `opacity`,
237
+ * `scale`, `rotate`, `x`, `y`. A scalar `scale` applies uniformly ; a
238
+ * `[sx, sy]` pair is collapsed to `sx` (framer takes a single scale on
239
+ * the motion components used here). `translate: [x, y]` `x` / `y`. */
240
+ function lowerAnimateState(s: LSMLAnimateState): Record<string, number> {
241
+ const out: Record<string, number> = {};
242
+ if (typeof s.opacity === "number") out["opacity"] = s.opacity;
243
+ const t = s.transform;
244
+ if (t) {
245
+ if (t.scale !== undefined) {
246
+ out["scale"] = Array.isArray(t.scale) ? t.scale[0] : t.scale;
247
+ }
248
+ if (typeof t.rotate === "number") out["rotate"] = t.rotate;
249
+ if (t.translate !== undefined) {
250
+ out["x"] = t.translate[0];
251
+ out["y"] = t.translate[1];
252
+ }
253
+ }
254
+ return out;
255
+ }
256
+
257
+ function compileRepeat(node: LSMLRepeat, opts: CompileOptions): RenderNode {
258
+ if (!node.bind?.items) {
259
+ throw new Error(`compiler: repeat node "${node.id ?? "<anon>"}" missing bind.items`);
260
+ }
261
+ const compiledTemplate = compileNode(node.template, opts);
262
+ const out: RenderNode = {
263
+ kind: "repeat",
264
+ bindings: { items: node.bind.items },
265
+ children: [compiledTemplate],
266
+ };
267
+ if (node.id !== undefined) out.id = node.id;
268
+ return out;
269
+ }
270
+
271
+ function mapTextStyle(node: LSMLText, props: Record<string, unknown>): void {
272
+ if (!node.style) return;
273
+ const s = node.style;
274
+ // Solar's Text primitive consumes size/weight/colour (UK), not CSS-style names.
275
+ if (s.fontSize !== undefined) props["size"] = s.fontSize;
276
+ if (s.fontFamily !== undefined) props["font"] = s.fontFamily;
277
+ if (s.fontWeight !== undefined) props["weight"] = s.fontWeight;
278
+ if (s.color !== undefined) props["colour"] = s.color;
279
+ if (s.textAlign !== undefined) props["align"] = mapTextAlign(s.textAlign);
280
+ if (s.lineHeight !== undefined) props["lineHeight"] = s.lineHeight;
281
+ }
282
+
283
+ function mapAlign(a: NonNullable<Extract<LSMLNode, { kind: "stack" }>["align"]>): string {
284
+ // LSML uses CSS-grid vocabulary; Solar's Stack consumes flexbox vocabulary.
285
+ switch (a) {
286
+ case "start":
287
+ return "flex-start";
288
+ case "center":
289
+ return "center";
290
+ case "end":
291
+ return "flex-end";
292
+ case "stretch":
293
+ return "stretch";
294
+ }
295
+ }
296
+
297
+ function mapJustify(j: NonNullable<Extract<LSMLNode, { kind: "stack" }>["justify"]>): string {
298
+ switch (j) {
299
+ case "start":
300
+ return "flex-start";
301
+ case "center":
302
+ return "center";
303
+ case "end":
304
+ return "flex-end";
305
+ case "space-between":
306
+ return "space-between";
307
+ case "space-around":
308
+ return "space-around";
309
+ }
310
+ }
311
+
312
+ function mapTextAlign(a: NonNullable<NonNullable<LSMLText["style"]>["textAlign"]>): string {
313
+ switch (a) {
314
+ case "start":
315
+ return "left";
316
+ case "end":
317
+ return "right";
318
+ default:
319
+ return a;
320
+ }
321
+ }
322
+
323
+ function compileAnimate(a: LSMLAnimateDirective):
324
+ | {
325
+ kind: "tween";
326
+ duration_ms: number;
327
+ ease?: "linear" | "cubic-in" | "cubic-out" | "cubic-in-out";
328
+ }
329
+ | { kind: "spring"; stiffness?: number; damping?: number }
330
+ | undefined {
331
+ const t = a.transition;
332
+ if (!t) return undefined;
333
+ if (t.easing === "spring") {
334
+ const out: { kind: "spring"; stiffness?: number; damping?: number } = { kind: "spring" };
335
+ if (t.stiffness !== undefined) out.stiffness = t.stiffness;
336
+ if (t.damping !== undefined) out.damping = t.damping;
337
+ return out;
338
+ }
339
+ return {
340
+ kind: "tween",
341
+ duration_ms: t.duration ?? 200,
342
+ ease: mapEase(t.easing),
343
+ };
344
+ }
345
+
346
+ function mapEase(
347
+ e: "linear" | "ease-in" | "ease-out" | "ease-in-out" | "spring" | undefined,
348
+ ): "linear" | "cubic-in" | "cubic-out" | "cubic-in-out" | undefined {
349
+ switch (e) {
350
+ case "linear":
351
+ return "linear";
352
+ case "ease-in":
353
+ return "cubic-in";
354
+ case "ease-out":
355
+ return "cubic-out";
356
+ case "ease-in-out":
357
+ return "cubic-in-out";
358
+ default:
359
+ return undefined;
360
+ }
361
+ }