@lumencast/compiler 0.3.0 → 0.5.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/README.md +48 -19
- package/dist/.tsbuildinfo +1 -1
- package/dist/compile.d.ts +41 -3
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +517 -15
- package/dist/compile.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lsml-types.d.ts +100 -0
- package/dist/lsml-types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/compile.ts +597 -18
- package/src/index.ts +19 -1
- package/src/lsml-types.ts +97 -0
package/src/compile.ts
CHANGED
|
@@ -26,29 +26,214 @@ import type {
|
|
|
26
26
|
LSMLAnimateDirective,
|
|
27
27
|
LSMLAnimateState,
|
|
28
28
|
LSMLBundle,
|
|
29
|
+
LSMLKeyframes,
|
|
29
30
|
LSMLNode,
|
|
31
|
+
LSMLPath,
|
|
30
32
|
LSMLRepeat,
|
|
31
33
|
LSMLText,
|
|
32
34
|
} from "./lsml-types.js";
|
|
33
35
|
|
|
36
|
+
/** Structured compile diagnostic (ADR 001 §3.4, issue #34). Per
|
|
37
|
+
* Bastion R9 it carries node identity + field + static reason and
|
|
38
|
+
* NEVER the offending value. */
|
|
39
|
+
export interface CompileDiagnostic {
|
|
40
|
+
/** `node.id` of the owning node, `"<anon>"` when absent, or
|
|
41
|
+
* `"<bundle>"` for bundle-level fields. */
|
|
42
|
+
nodeId: string;
|
|
43
|
+
/** Field name (e.g. `effects`, `style.textShadow`, `keyframes.steps[0].filter.blur`). */
|
|
44
|
+
field: string;
|
|
45
|
+
/** Static reason — why the field warns. Never includes the value. */
|
|
46
|
+
reason: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
export interface CompileOptions {
|
|
35
|
-
/** When true, throws on any unrecognized LSML extension
|
|
50
|
+
/** When true, throws on any warning — unrecognized LSML extension,
|
|
51
|
+
* spec'd-but-not-lowered field, clamped value. Default false (warn-only). */
|
|
36
52
|
strict?: boolean;
|
|
37
|
-
/** Optional warn collector — receives
|
|
38
|
-
|
|
53
|
+
/** Optional warn collector — receives the formatted message plus the
|
|
54
|
+
* structured diagnostic (additive second argument, issue #34). */
|
|
55
|
+
onWarn?: (message: string, diagnostic: CompileDiagnostic) => void;
|
|
39
56
|
}
|
|
40
57
|
|
|
41
58
|
const SUPPORTED_VERSIONS = new Set(["1.0", "1.1"] as const);
|
|
42
59
|
|
|
60
|
+
// --- hard caps (ADR 001 §5.1 R8 + §6 RC#10, threat model Bastion) ------
|
|
61
|
+
//
|
|
62
|
+
// Filter clamps — an unbounded `filter` is a compositing DoS in CEF.
|
|
63
|
+
/** Max CSS `blur()` radius emitted by the compiler, in px. */
|
|
64
|
+
export const MAX_FILTER_BLUR_PX = 100;
|
|
65
|
+
/** Max CSS `brightness()` factor emitted by the compiler (spec §6.1
|
|
66
|
+
* explicitly blesses clamping to 4). */
|
|
67
|
+
export const MAX_FILTER_BRIGHTNESS = 4;
|
|
68
|
+
// Path caps — `d` strings are untrusted author input rendered into SVG.
|
|
69
|
+
/** Max size of a single subpath `d` string (8 KiB, RC#10). */
|
|
70
|
+
export const MAX_PATH_SUBPATH_BYTES = 8192;
|
|
71
|
+
/** Max number of subpaths on a single shape (RC#10). */
|
|
72
|
+
export const MAX_PATH_SUBPATHS = 64;
|
|
73
|
+
/** Max number of path commands per subpath (RC#10). Kept below the
|
|
74
|
+
* densest possible command packing within the byte cap (single-letter
|
|
75
|
+
* `Z` spam = 1 command/byte) so the cap is actually reachable. */
|
|
76
|
+
export const MAX_PATH_COMMANDS = 4000;
|
|
77
|
+
|
|
78
|
+
/** Runtime keyframes shape (`@lumencast/runtime` `Keyframes`), referenced
|
|
79
|
+
* through `RenderNode` so the compiler stays a type-only consumer. */
|
|
80
|
+
type RuntimeKeyframes = NonNullable<RenderNode["keyframes"]>;
|
|
81
|
+
type RuntimeKeyframeStep = RuntimeKeyframes["steps"][number];
|
|
82
|
+
|
|
83
|
+
/** Emit an anti-silent-drop diagnostic (ADR 001 §3.4). Per Bastion R9 the
|
|
84
|
+
* message carries `node.id` + field + reason and NEVER the offending
|
|
85
|
+
* value (leaf/prop values can carry sensitive on-air content). */
|
|
86
|
+
function warn(
|
|
87
|
+
opts: CompileOptions,
|
|
88
|
+
nodeId: string | undefined,
|
|
89
|
+
field: string,
|
|
90
|
+
reason: string,
|
|
91
|
+
): void {
|
|
92
|
+
const diagnostic: CompileDiagnostic = { nodeId: nodeId ?? "<anon>", field, reason };
|
|
93
|
+
const message = `compiler: node "${diagnostic.nodeId}": field "${field}" ${reason}`;
|
|
94
|
+
if (opts.strict) throw new Error(message);
|
|
95
|
+
opts.onWarn?.(message, diagnostic);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Hard compile error — invalid value. Per R9 the message names the node
|
|
99
|
+
* and field but never echoes the value itself. */
|
|
100
|
+
function invalid(nodeId: string | undefined, field: string, reason: string): Error {
|
|
101
|
+
return new Error(`compiler: node "${nodeId ?? "<anon>"}": field "${field}" ${reason}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- consumed-key accounting (ADR 001 §3.4 D4, issue #34) --------------
|
|
105
|
+
//
|
|
106
|
+
// Every key present on a source node (or the bundle) and NOT consumed by
|
|
107
|
+
// the lowering below produces an `onWarn` diagnostic — whether the key is
|
|
108
|
+
// spec'd-but-unsupported or an unknown extension. `strict: true` turns
|
|
109
|
+
// every warning into a throw. Exemptions (advisory by construction,
|
|
110
|
+
// §17.5.1) : `metadata` blocks at every level ; authoring profiles are
|
|
111
|
+
// forwarded verbatim, never key-audited.
|
|
112
|
+
//
|
|
113
|
+
// The sets below mirror EXACTLY what `compileNode` / `compileRepeat` /
|
|
114
|
+
// `mapTextStyle` read. When extending the lowering, add the new key here
|
|
115
|
+
// in the same change — a forgotten entry shows up as a spurious warning
|
|
116
|
+
// in the fixture suite, never as a silent drop.
|
|
117
|
+
|
|
118
|
+
/** Advisory, never audited (LSML §17.4 / §17.5.1). */
|
|
119
|
+
const EXEMPT_KEYS = new Set(["metadata"]);
|
|
120
|
+
|
|
121
|
+
/** Keys the common (non-repeat) lowering path consumes on every node. */
|
|
122
|
+
const COMMON_NODE_KEYS: ReadonlySet<string> = new Set([
|
|
123
|
+
"kind",
|
|
124
|
+
"id",
|
|
125
|
+
"bind",
|
|
126
|
+
"bindStyle",
|
|
127
|
+
"bindUniversal",
|
|
128
|
+
"bindAnimate",
|
|
129
|
+
"animate",
|
|
130
|
+
"keyframes",
|
|
131
|
+
"children",
|
|
132
|
+
"visible",
|
|
133
|
+
"opacity",
|
|
134
|
+
"rotation",
|
|
135
|
+
"sizing",
|
|
136
|
+
"position",
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
/** Keys `compileRepeat` consumes. `scope` names the iteration scope the
|
|
140
|
+
* template's bind paths reference — declarative, no lowering needed. */
|
|
141
|
+
const REPEAT_NODE_KEYS: ReadonlySet<string> = new Set([
|
|
142
|
+
"kind",
|
|
143
|
+
"id",
|
|
144
|
+
"bind",
|
|
145
|
+
"scope",
|
|
146
|
+
"template",
|
|
147
|
+
"stagger_ms",
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
/** Per-kind keys consumed by the `switch` in `compileNode`. */
|
|
151
|
+
const KIND_NODE_KEYS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
152
|
+
stack: new Set(["direction", "gap", "align", "justify", "padding", "rtl"]),
|
|
153
|
+
grid: new Set(["columns", "rows", "gap", "padding"]),
|
|
154
|
+
frame: new Set(["size", "position", "background", "backgrounds", "clipsContent"]),
|
|
155
|
+
text: new Set(["style", "format", "maxLines"]),
|
|
156
|
+
image: new Set(["alt", "size", "fit"]),
|
|
157
|
+
shape: new Set([
|
|
158
|
+
"geometry",
|
|
159
|
+
"size",
|
|
160
|
+
"pathData",
|
|
161
|
+
"paths",
|
|
162
|
+
"fill",
|
|
163
|
+
"fills",
|
|
164
|
+
"stroke",
|
|
165
|
+
"strokes",
|
|
166
|
+
"cornerRadius",
|
|
167
|
+
"ariaLabel",
|
|
168
|
+
]),
|
|
169
|
+
media: new Set(["kind_hint", "controls", "autoplay", "muted", "loop", "size"]),
|
|
170
|
+
instance: new Set(["scene_id", "scene_version", "size", "fit", "params", "bindParams"]),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** Keys `mapTextStyle` consumes inside `text.style`. */
|
|
174
|
+
const TEXT_STYLE_KEYS: ReadonlySet<string> = new Set([
|
|
175
|
+
"fontSize",
|
|
176
|
+
"fontFamily",
|
|
177
|
+
"fontWeight",
|
|
178
|
+
"color",
|
|
179
|
+
"textAlign",
|
|
180
|
+
"lineHeight",
|
|
181
|
+
"letterSpacing",
|
|
182
|
+
"textTransform",
|
|
183
|
+
"textDecoration",
|
|
184
|
+
"fontStyle",
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
/** Keys `compileBundle` consumes at the bundle level. */
|
|
188
|
+
const BUNDLE_KEYS: ReadonlySet<string> = new Set([
|
|
189
|
+
"lsml",
|
|
190
|
+
"$schema",
|
|
191
|
+
"scene_id",
|
|
192
|
+
"scene_version",
|
|
193
|
+
"profiles",
|
|
194
|
+
"layout",
|
|
195
|
+
"operator_inputs",
|
|
196
|
+
"external_adapters",
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
const NOT_LOWERED =
|
|
200
|
+
"is not lowered by this compiler ; without this diagnostic the field would drop silently (ADR 001 §3.4)";
|
|
201
|
+
|
|
202
|
+
/** Diff a node's present keys against the consumed sets. */
|
|
203
|
+
function auditNodeKeys(node: LSMLNode, opts: CompileOptions): void {
|
|
204
|
+
const allowed = node.kind === "repeat" ? REPEAT_NODE_KEYS : COMMON_NODE_KEYS;
|
|
205
|
+
const kindKeys = node.kind === "repeat" ? undefined : KIND_NODE_KEYS[node.kind];
|
|
206
|
+
for (const key of Object.keys(node)) {
|
|
207
|
+
if (EXEMPT_KEYS.has(key) || allowed.has(key) || kindKeys?.has(key)) continue;
|
|
208
|
+
warn(opts, node.id, key, NOT_LOWERED);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Diff bundle-level keys (`defaults`, `assets`, `i18n` and unknown
|
|
213
|
+
* extensions warn today — none of them lands in the RenderBundle). */
|
|
214
|
+
function auditBundleKeys(lsml: LSMLBundle, opts: CompileOptions): void {
|
|
215
|
+
for (const key of Object.keys(lsml)) {
|
|
216
|
+
if (EXEMPT_KEYS.has(key) || BUNDLE_KEYS.has(key)) continue;
|
|
217
|
+
warn(opts, "<bundle>", key, NOT_LOWERED);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
43
221
|
export function compileBundle(lsml: LSMLBundle, options: CompileOptions = {}): RenderBundle {
|
|
44
222
|
if (!SUPPORTED_VERSIONS.has(lsml.lsml as "1.0" | "1.1")) {
|
|
45
223
|
throw new Error(
|
|
46
224
|
`compiler: LSML version "${lsml.lsml}" is not supported (supported: ${[...SUPPORTED_VERSIONS].join(", ")})`,
|
|
47
225
|
);
|
|
48
226
|
}
|
|
227
|
+
auditBundleKeys(lsml, options);
|
|
49
228
|
return {
|
|
50
229
|
scene_version: lsml.scene_version,
|
|
51
230
|
root: compileNode(lsml.layout, options),
|
|
231
|
+
// LSML 1.1 §17.3 — forward `profiles[]` verbatim so the runtime applies
|
|
232
|
+
// the same gating rule (§17.3.1 hard rejection for unsupported
|
|
233
|
+
// behavioural profiles, §17.5.1 advisory pass-through for authoring
|
|
234
|
+
// profiles) on the compiled path as on the direct-fetch path (ADR 001
|
|
235
|
+
// §3.2.1, RC#1).
|
|
236
|
+
...(lsml.profiles !== undefined ? { profiles: [...lsml.profiles] } : {}),
|
|
52
237
|
...(lsml.operator_inputs
|
|
53
238
|
? {
|
|
54
239
|
operator_inputs: lsml.operator_inputs.map((oi) => ({
|
|
@@ -70,6 +255,11 @@ export function compileBundle(lsml: LSMLBundle, options: CompileOptions = {}): R
|
|
|
70
255
|
}
|
|
71
256
|
|
|
72
257
|
function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
|
|
258
|
+
// ADR 001 §3.4 (issue #34) — every present key must be consumed by the
|
|
259
|
+
// lowering below, exempt (`metadata`), or diagnosed. Runs for repeat
|
|
260
|
+
// nodes too (their consumed set differs).
|
|
261
|
+
auditNodeKeys(node, opts);
|
|
262
|
+
|
|
73
263
|
if (node.kind === "repeat") {
|
|
74
264
|
return compileRepeat(node, opts);
|
|
75
265
|
}
|
|
@@ -111,10 +301,16 @@ function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
|
|
|
111
301
|
props["y"] = node.position.y;
|
|
112
302
|
}
|
|
113
303
|
if (node.background !== undefined) props["background"] = node.background;
|
|
304
|
+
// 1.1 §4.3 + §4.12 — stacked backgrounds (frame.tsx reads
|
|
305
|
+
// `resolved.backgrounds`; array form wins over legacy `background`).
|
|
306
|
+
if (node.backgrounds !== undefined) props["backgrounds"] = node.backgrounds;
|
|
307
|
+
// 1.1 §4.3 — clip children to the frame bounds. The spec default
|
|
308
|
+
// (`true`) is applied runtime-side ; only explicit values forward.
|
|
309
|
+
if (node.clipsContent !== undefined) props["clipsContent"] = node.clipsContent;
|
|
114
310
|
break;
|
|
115
311
|
|
|
116
312
|
case "text":
|
|
117
|
-
mapTextStyle(node, props);
|
|
313
|
+
mapTextStyle(node, props, opts);
|
|
118
314
|
if (node.format !== undefined) props["format"] = node.format;
|
|
119
315
|
if (node.maxLines !== undefined) props["maxLines"] = node.maxLines;
|
|
120
316
|
break;
|
|
@@ -132,10 +328,36 @@ function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
|
|
|
132
328
|
props["width"] = node.size.w;
|
|
133
329
|
props["height"] = node.size.h;
|
|
134
330
|
}
|
|
135
|
-
|
|
331
|
+
// Path geometry — every `d` string is untrusted author input that
|
|
332
|
+
// ends up in an SVG attribute. Validate at compile per RC#10 (the
|
|
333
|
+
// runtime re-validates live deltas in its own gate — issue #30).
|
|
334
|
+
if (node.pathData !== undefined) {
|
|
335
|
+
validatePathData(node.pathData, node.id, "pathData");
|
|
336
|
+
props["pathData"] = node.pathData;
|
|
337
|
+
}
|
|
338
|
+
if (node.paths !== undefined) {
|
|
339
|
+
props["paths"] = lowerPaths(node.paths, node.id);
|
|
340
|
+
if (node.pathData !== undefined) {
|
|
341
|
+
// §4.6 declares the two forms mutually exclusive ; we keep
|
|
342
|
+
// both forwarded (runtime prefers `paths`) but surface it.
|
|
343
|
+
warn(opts, node.id, "pathData", "is mutually exclusive with paths[] (LSML §4.6)");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
136
346
|
if (node.fill !== undefined) props["fill"] = node.fill;
|
|
137
|
-
|
|
138
|
-
if (node.
|
|
347
|
+
// 1.1 §4.6 + §4.12 — stacked fills (shape.tsx reads `resolved.fills`).
|
|
348
|
+
if (node.fills !== undefined) props["fills"] = node.fills;
|
|
349
|
+
// Single stroke lowers to the flat props shape.tsx consumes
|
|
350
|
+
// (`stroke` = colour string, `stroke_width` = number). The previous
|
|
351
|
+
// object forward was silently unrenderable.
|
|
352
|
+
if (node.stroke !== undefined) {
|
|
353
|
+
props["stroke"] = node.stroke.color;
|
|
354
|
+
props["stroke_width"] = node.stroke.width;
|
|
355
|
+
}
|
|
356
|
+
// 1.1 §4.6 — stacked strokes (shape.tsx reads `resolved.strokes`).
|
|
357
|
+
if (node.strokes !== undefined) props["strokes"] = node.strokes;
|
|
358
|
+
// Canonical RenderNode name is `radius` (what shape.tsx reads) ;
|
|
359
|
+
// the previous `cornerRadius` forward was silently dropped.
|
|
360
|
+
if (node.cornerRadius !== undefined) props["radius"] = node.cornerRadius;
|
|
139
361
|
if (node.ariaLabel !== undefined) props["ariaLabel"] = node.ariaLabel;
|
|
140
362
|
break;
|
|
141
363
|
|
|
@@ -202,12 +424,34 @@ function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
|
|
|
202
424
|
if (tx) {
|
|
203
425
|
const transitions: Record<string, ReturnType<typeof compileAnimate>> = {};
|
|
204
426
|
if (node.animate.opacity !== undefined) transitions["opacity"] = tx;
|
|
205
|
-
if (node.animate.transform?.scale !== undefined)
|
|
427
|
+
if (node.animate.transform?.scale !== undefined) {
|
|
428
|
+
// Per-axis `[sx, sy]` lowers to framer's `scaleX` / `scaleY`
|
|
429
|
+
// motion keys ; a scalar stays on uniform `scale`.
|
|
430
|
+
if (Array.isArray(node.animate.transform.scale)) {
|
|
431
|
+
transitions["scaleX"] = tx;
|
|
432
|
+
transitions["scaleY"] = tx;
|
|
433
|
+
} else {
|
|
434
|
+
transitions["scale"] = tx;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
206
437
|
if (node.animate.transform?.rotate !== undefined) transitions["rotate"] = tx;
|
|
207
438
|
if (node.animate.transform?.translate !== undefined) {
|
|
208
439
|
transitions["x"] = tx;
|
|
209
440
|
transitions["y"] = tx;
|
|
210
441
|
}
|
|
442
|
+
// §6.1 — `filter` is animatable ; previously dropped in silence.
|
|
443
|
+
if (node.animate.filter !== undefined) transitions["filter"] = tx;
|
|
444
|
+
// §6.3 — a bound animation channel must honour the declared
|
|
445
|
+
// `animate.transition` too : emit a per-prop transition entry for
|
|
446
|
+
// every bindAnimate key so the runtime retargets with the
|
|
447
|
+
// authored timing instead of its default spring.
|
|
448
|
+
if (node.bindAnimate) {
|
|
449
|
+
for (const key of Object.keys(node.bindAnimate)) {
|
|
450
|
+
for (const fk of transitionKeysForBindAnimate(key, node.kind)) {
|
|
451
|
+
transitions[fk] = tx;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
211
455
|
// Type assertion: RenderNode.transitions matches Transition shape; the
|
|
212
456
|
// cast keeps the compiler self-contained without re-importing the runtime
|
|
213
457
|
// Transition type.
|
|
@@ -222,28 +466,333 @@ function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
|
|
|
222
466
|
// timing). When no `from` is present, `animate_initial` is omitted
|
|
223
467
|
// and the prior no-mount-play behaviour is preserved (rétro-compat).
|
|
224
468
|
if (node.animate.from) {
|
|
225
|
-
const initial = lowerAnimateState(node.animate.from);
|
|
469
|
+
const initial = lowerAnimateState(node.animate.from, node.id, opts);
|
|
226
470
|
if (Object.keys(initial).length > 0) {
|
|
227
471
|
out.animate_initial = initial;
|
|
228
472
|
}
|
|
229
473
|
}
|
|
230
474
|
}
|
|
231
475
|
|
|
476
|
+
// §6.3 `bindAnimate` → RenderNode `animateBindings` (ADR 001 §3.3,
|
|
477
|
+
// issue #33). Validation is HARD : any key outside the animatable set
|
|
478
|
+
// throws at compile (RC#13 — a malformed bindAnimate is an invalid
|
|
479
|
+
// directive, the warn-by-default policy §3.4 does not apply).
|
|
480
|
+
if (node.bindAnimate !== undefined) {
|
|
481
|
+
const lowered = lowerBindAnimate(node.bindAnimate, node.kind, node.id);
|
|
482
|
+
if (Object.keys(lowered).length > 0) out.animateBindings = lowered;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// LSML 1.1 §6.6 — keyframe sequence, lowered to the runtime
|
|
486
|
+
// `Keyframes` shape consumed by KeyframePlayer.
|
|
487
|
+
if (node.keyframes !== undefined) {
|
|
488
|
+
out.keyframes = lowerKeyframes(node.keyframes, node.id, opts);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return out;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// --- bindAnimate lowering (LSML §6.3 — ADR 001 §3.3, RC#13) ------------
|
|
495
|
+
//
|
|
496
|
+
// `bindAnimate` keys MUST reference animatable properties only : the
|
|
497
|
+
// §6.1 GPU-composited list, plus the node kind's colour-typed property
|
|
498
|
+
// blessed by §6.5 for continuous colour interpolation. Anything else
|
|
499
|
+
// (layout properties, unknown channels) is a HARD compile error — never
|
|
500
|
+
// a warning (RC#13, exception to the §3.4 warn-by-default policy : an
|
|
501
|
+
// invalid directive is not a "spec'd field we don't support yet").
|
|
502
|
+
|
|
503
|
+
/** §6.1 animatable property keys accepted on every primitive. */
|
|
504
|
+
export const BIND_ANIMATE_SCALAR_KEYS: ReadonlySet<string> = new Set([
|
|
505
|
+
"opacity",
|
|
506
|
+
"transform.translate",
|
|
507
|
+
"transform.scale",
|
|
508
|
+
"transform.rotate",
|
|
509
|
+
"filter.blur",
|
|
510
|
+
"filter.brightness",
|
|
511
|
+
]);
|
|
512
|
+
|
|
513
|
+
/** §6.5 colour-typed properties reachable via bindAnimate, per node
|
|
514
|
+
* kind. Kept to the single-colour core props for now ; `fills[].color`
|
|
515
|
+
* / `backgrounds[]` entries are array-typed and stay out of scope
|
|
516
|
+
* (documented in ADR 001 phase B). */
|
|
517
|
+
export const BIND_ANIMATE_COLOR_KEYS: Readonly<Record<string, string>> = {
|
|
518
|
+
text: "style.color",
|
|
519
|
+
shape: "fill",
|
|
520
|
+
frame: "background",
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
function isBindAnimateKeyAllowed(key: string, kind: string): boolean {
|
|
524
|
+
if (BIND_ANIMATE_SCALAR_KEYS.has(key)) return true;
|
|
525
|
+
return BIND_ANIMATE_COLOR_KEYS[kind] === key;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Validate + lower a §6.3 `bindAnimate` map. Keys keep their spec
|
|
529
|
+
* names verbatim (the runtime maps them onto motion channels) ; values
|
|
530
|
+
* must be non-empty LeafPath strings. Throws on any violation (RC#13) ;
|
|
531
|
+
* per R9 the error names the node + key, never the bound value. */
|
|
532
|
+
function lowerBindAnimate(
|
|
533
|
+
bind: Record<string, string>,
|
|
534
|
+
kind: string,
|
|
535
|
+
nodeId: string | undefined,
|
|
536
|
+
): Record<string, string> {
|
|
537
|
+
const out: Record<string, string> = {};
|
|
538
|
+
for (const [key, path] of Object.entries(bind)) {
|
|
539
|
+
if (!isBindAnimateKeyAllowed(key, kind)) {
|
|
540
|
+
throw invalid(
|
|
541
|
+
nodeId,
|
|
542
|
+
`bindAnimate.${key}`,
|
|
543
|
+
"is not an animatable property for this primitive (LSML §6.1/§6.5, RC#13)",
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
547
|
+
throw invalid(nodeId, `bindAnimate.${key}`, "must bind a non-empty LeafPath string");
|
|
548
|
+
}
|
|
549
|
+
out[key] = path;
|
|
550
|
+
}
|
|
232
551
|
return out;
|
|
233
552
|
}
|
|
234
553
|
|
|
554
|
+
/** The per-prop `transitions` entries a bound channel resolves at the
|
|
555
|
+
* runtime (mirrors the static-target lowering above : translate drives
|
|
556
|
+
* framer `x`/`y`, scale may be per-axis, filter channels share the
|
|
557
|
+
* composed `filter` entry, colour channels use the runtime prop name). */
|
|
558
|
+
function transitionKeysForBindAnimate(key: string, kind: string): string[] {
|
|
559
|
+
switch (key) {
|
|
560
|
+
case "opacity":
|
|
561
|
+
return ["opacity"];
|
|
562
|
+
case "transform.translate":
|
|
563
|
+
return ["x", "y"];
|
|
564
|
+
case "transform.scale":
|
|
565
|
+
return ["scale", "scaleX", "scaleY"];
|
|
566
|
+
case "transform.rotate":
|
|
567
|
+
return ["rotate"];
|
|
568
|
+
case "filter.blur":
|
|
569
|
+
case "filter.brightness":
|
|
570
|
+
return ["filter"];
|
|
571
|
+
default:
|
|
572
|
+
// Colour channel — the runtime looks the transition up under the
|
|
573
|
+
// primitive's prop name (text → `colour`, shape → `fill`,
|
|
574
|
+
// frame → `background`).
|
|
575
|
+
if (BIND_ANIMATE_COLOR_KEYS[kind] === key) {
|
|
576
|
+
return [kind === "text" ? "colour" : key];
|
|
577
|
+
}
|
|
578
|
+
return [];
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// --- path validation (ADR 001 §6 RC#10 — compile-side gate) -----------
|
|
583
|
+
//
|
|
584
|
+
// SVG `d` strings are untrusted author input rendered into a DOM
|
|
585
|
+
// attribute. The grammar is ALLOWLISTED : path command letters
|
|
586
|
+
// `MmLlHhVvCcSsQqTtAaZz` plus number/separator characters only. This
|
|
587
|
+
// rejects `url(`, `data:`, `<` and `&` by construction (none of their
|
|
588
|
+
// characters are in the allowlist). Implemented as a single-pass manual
|
|
589
|
+
// scanner — linear time, no regex, no backtracking (anti-ReDoS, RC#12).
|
|
590
|
+
//
|
|
591
|
+
// Known limitation (by design) : the scanner is CHAR-LEVEL, not a number
|
|
592
|
+
// grammar. Malformed numerics built from allowlisted characters (`1.2.3`,
|
|
593
|
+
// `+-+5`, overflow exponents like `1e9999`) pass validation. This is
|
|
594
|
+
// accepted : a syntactically invalid `d` is simply ignored by the
|
|
595
|
+
// browser's SVG path parser (the path does not render), so there is no
|
|
596
|
+
// injection or DoS vector — only the author's own shape failing to draw.
|
|
597
|
+
// Upgrading to a full number grammar would add parser complexity for no
|
|
598
|
+
// security gain.
|
|
599
|
+
const PATH_COMMANDS = new Set("MmLlHhVvCcSsQqTtAaZz");
|
|
600
|
+
|
|
601
|
+
function isPathNumberChar(ch: string): boolean {
|
|
602
|
+
return (ch >= "0" && ch <= "9") || ch === "." || ch === "+" || ch === "-" || ch === ",";
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function isPathWhitespace(ch: string): boolean {
|
|
606
|
+
return ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Validate one subpath `d` string. Throws on any violation (size cap,
|
|
610
|
+
* command cap, character outside the allowlist). Error messages name
|
|
611
|
+
* the node + field but never echo the string (R9). */
|
|
612
|
+
export function validatePathData(d: string, nodeId: string | undefined, field: string): void {
|
|
613
|
+
if (typeof d !== "string" || d.length === 0) {
|
|
614
|
+
throw invalid(nodeId, field, "must be a non-empty SVG path string");
|
|
615
|
+
}
|
|
616
|
+
// Allowlisted grammar is ASCII-only, so UTF-16 length === byte length
|
|
617
|
+
// for any string that passes the scan ; checking the cap first keeps
|
|
618
|
+
// the scan itself bounded.
|
|
619
|
+
if (d.length > MAX_PATH_SUBPATH_BYTES) {
|
|
620
|
+
throw invalid(nodeId, field, `exceeds the ${MAX_PATH_SUBPATH_BYTES}-byte subpath cap (RC#10)`);
|
|
621
|
+
}
|
|
622
|
+
let commands = 0;
|
|
623
|
+
for (let i = 0; i < d.length; i++) {
|
|
624
|
+
const ch = d[i] as string;
|
|
625
|
+
if (PATH_COMMANDS.has(ch)) {
|
|
626
|
+
commands++;
|
|
627
|
+
if (commands > MAX_PATH_COMMANDS) {
|
|
628
|
+
throw invalid(
|
|
629
|
+
nodeId,
|
|
630
|
+
field,
|
|
631
|
+
`exceeds the ${MAX_PATH_COMMANDS}-command subpath cap (RC#10)`,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (isPathNumberChar(ch) || isPathWhitespace(ch)) continue;
|
|
637
|
+
// Exponent marker — only valid immediately after a digit or dot
|
|
638
|
+
// (e.g. `1e3`, `1.5E-2`). A bare `e`/`E` is rejected.
|
|
639
|
+
if ((ch === "e" || ch === "E") && i > 0) {
|
|
640
|
+
const prev = d[i - 1] as string;
|
|
641
|
+
if ((prev >= "0" && prev <= "9") || prev === ".") continue;
|
|
642
|
+
}
|
|
643
|
+
throw invalid(nodeId, field, `contains a character outside the SVG path allowlist (RC#10)`);
|
|
644
|
+
}
|
|
645
|
+
if (commands === 0) {
|
|
646
|
+
throw invalid(nodeId, field, "contains no SVG path command");
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Validate + forward `paths[]` (LSML §4.6). Caps the subpath count and
|
|
651
|
+
* validates every `data` string. */
|
|
652
|
+
function lowerPaths(
|
|
653
|
+
paths: LSMLPath[],
|
|
654
|
+
nodeId: string | undefined,
|
|
655
|
+
): { data: string; windingRule?: "NONZERO" | "EVENODD" }[] {
|
|
656
|
+
if (paths.length === 0) {
|
|
657
|
+
throw invalid(nodeId, "paths", "must contain at least one subpath");
|
|
658
|
+
}
|
|
659
|
+
if (paths.length > MAX_PATH_SUBPATHS) {
|
|
660
|
+
throw invalid(nodeId, "paths", `exceeds the ${MAX_PATH_SUBPATHS}-subpath cap (RC#10)`);
|
|
661
|
+
}
|
|
662
|
+
return paths.map((p, i) => {
|
|
663
|
+
validatePathData(p.data, nodeId, `paths[${i}].data`);
|
|
664
|
+
return {
|
|
665
|
+
data: p.data,
|
|
666
|
+
...(p.windingRule !== undefined ? { windingRule: p.windingRule } : {}),
|
|
667
|
+
};
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// --- filter lowering (ADR 001 §5.1 R8 — hard clamps, non-optional) -----
|
|
672
|
+
|
|
673
|
+
/** Lower an LSML `filter` state (`{ blur?, brightness? }`) to the CSS
|
|
674
|
+
* filter string framer-motion animates. Values are HARD-clamped at
|
|
675
|
+
* lowering : negative / non-finite values are rejected (compile error),
|
|
676
|
+
* `blur` caps at MAX_FILTER_BLUR_PX, `brightness` caps at
|
|
677
|
+
* MAX_FILTER_BRIGHTNESS. Both functions are always emitted so framer
|
|
678
|
+
* can interpolate between structurally-identical filter lists. */
|
|
679
|
+
function lowerFilter(
|
|
680
|
+
f: NonNullable<LSMLAnimateState["filter"]>,
|
|
681
|
+
nodeId: string | undefined,
|
|
682
|
+
field: string,
|
|
683
|
+
opts: CompileOptions,
|
|
684
|
+
): string {
|
|
685
|
+
let blur = 0;
|
|
686
|
+
let brightness = 1;
|
|
687
|
+
if (f.blur !== undefined) {
|
|
688
|
+
// `-0 < 0` is false in IEEE-754 — Object.is closes the negative-zero hole.
|
|
689
|
+
if (
|
|
690
|
+
typeof f.blur !== "number" ||
|
|
691
|
+
!Number.isFinite(f.blur) ||
|
|
692
|
+
f.blur < 0 ||
|
|
693
|
+
Object.is(f.blur, -0)
|
|
694
|
+
) {
|
|
695
|
+
throw invalid(nodeId, `${field}.blur`, "must be a finite number >= 0 (R8)");
|
|
696
|
+
}
|
|
697
|
+
blur = f.blur;
|
|
698
|
+
if (blur > MAX_FILTER_BLUR_PX) {
|
|
699
|
+
blur = MAX_FILTER_BLUR_PX;
|
|
700
|
+
warn(opts, nodeId, `${field}.blur`, `clamped to the ${MAX_FILTER_BLUR_PX}px cap (R8)`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (f.brightness !== undefined) {
|
|
704
|
+
// Same -0 gate as blur : `brightness(-0)` stringifies to `brightness(0)`,
|
|
705
|
+
// a fully black element slipping past the negative-value rejection (R8).
|
|
706
|
+
if (
|
|
707
|
+
typeof f.brightness !== "number" ||
|
|
708
|
+
!Number.isFinite(f.brightness) ||
|
|
709
|
+
f.brightness < 0 ||
|
|
710
|
+
Object.is(f.brightness, -0)
|
|
711
|
+
) {
|
|
712
|
+
throw invalid(nodeId, `${field}.brightness`, "must be a finite number >= 0 (R8)");
|
|
713
|
+
}
|
|
714
|
+
brightness = f.brightness;
|
|
715
|
+
if (brightness > MAX_FILTER_BRIGHTNESS) {
|
|
716
|
+
brightness = MAX_FILTER_BRIGHTNESS;
|
|
717
|
+
warn(opts, nodeId, `${field}.brightness`, `clamped to the ${MAX_FILTER_BRIGHTNESS} cap (R8)`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return `blur(${blur}px) brightness(${brightness})`;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// --- keyframes lowering (LSML §6.6 → runtime Keyframes shape) ----------
|
|
724
|
+
|
|
725
|
+
/** Lower a §6.6 keyframe sequence into the shape KeyframePlayer /
|
|
726
|
+
* compileForFramer consume : `transform.translate: [x, y]` →
|
|
727
|
+
* `translateX` / `translateY`, `filter: { blur, brightness }` → clamped
|
|
728
|
+
* CSS string. Per-axis step scale degrades to `sx` with a diagnostic
|
|
729
|
+
* (the runtime keyframe channel is uniform-scale ; per-axis keyframe
|
|
730
|
+
* scale lands with ADR 001 phase B/C). */
|
|
731
|
+
function lowerKeyframes(
|
|
732
|
+
kf: LSMLKeyframes,
|
|
733
|
+
nodeId: string | undefined,
|
|
734
|
+
opts: CompileOptions,
|
|
735
|
+
): RuntimeKeyframes {
|
|
736
|
+
const steps: RuntimeKeyframeStep[] = kf.steps.map((s, i) => {
|
|
737
|
+
const step: RuntimeKeyframeStep = { at: s.at };
|
|
738
|
+
if (s.opacity !== undefined) step.opacity = s.opacity;
|
|
739
|
+
if (s.filter !== undefined) {
|
|
740
|
+
step.filter = lowerFilter(s.filter, nodeId, `keyframes.steps[${i}].filter`, opts);
|
|
741
|
+
}
|
|
742
|
+
const t = s.transform;
|
|
743
|
+
if (t) {
|
|
744
|
+
const transform: NonNullable<RuntimeKeyframeStep["transform"]> = {};
|
|
745
|
+
if (t.scale !== undefined) {
|
|
746
|
+
if (Array.isArray(t.scale)) {
|
|
747
|
+
transform.scale = t.scale[0];
|
|
748
|
+
warn(
|
|
749
|
+
opts,
|
|
750
|
+
nodeId,
|
|
751
|
+
`keyframes.steps[${i}].transform.scale`,
|
|
752
|
+
"per-axis scale is not supported in keyframes yet ; lowered to the x-axis value",
|
|
753
|
+
);
|
|
754
|
+
} else {
|
|
755
|
+
transform.scale = t.scale;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (t.rotate !== undefined) transform.rotate = t.rotate;
|
|
759
|
+
if (t.translate !== undefined) {
|
|
760
|
+
transform.translateX = t.translate[0];
|
|
761
|
+
transform.translateY = t.translate[1];
|
|
762
|
+
}
|
|
763
|
+
if (Object.keys(transform).length > 0) step.transform = transform;
|
|
764
|
+
}
|
|
765
|
+
return step;
|
|
766
|
+
});
|
|
767
|
+
return {
|
|
768
|
+
...(kf.key !== undefined ? { key: kf.key } : {}),
|
|
769
|
+
steps,
|
|
770
|
+
duration_ms: kf.duration_ms,
|
|
771
|
+
...(kf.easing !== undefined ? { easing: kf.easing } : {}),
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
235
775
|
/** Lower an `animate.from` (or any LSML animate state) into the flat
|
|
236
776
|
* framer-motion key space the runtime primitives consume: `opacity`,
|
|
237
|
-
* `scale
|
|
238
|
-
* `
|
|
239
|
-
*
|
|
240
|
-
function lowerAnimateState(
|
|
241
|
-
|
|
777
|
+
* `scale` (or `scaleX`/`scaleY` for a per-axis `[sx, sy]` pair),
|
|
778
|
+
* `rotate`, `x`, `y`, `filter` (clamped CSS string, R8).
|
|
779
|
+
* `translate: [x, y]` → `x` / `y`. */
|
|
780
|
+
function lowerAnimateState(
|
|
781
|
+
s: LSMLAnimateState,
|
|
782
|
+
nodeId: string | undefined,
|
|
783
|
+
opts: CompileOptions,
|
|
784
|
+
): Record<string, number | string> {
|
|
785
|
+
const out: Record<string, number | string> = {};
|
|
242
786
|
if (typeof s.opacity === "number") out["opacity"] = s.opacity;
|
|
243
787
|
const t = s.transform;
|
|
244
788
|
if (t) {
|
|
245
789
|
if (t.scale !== undefined) {
|
|
246
|
-
|
|
790
|
+
if (Array.isArray(t.scale)) {
|
|
791
|
+
out["scaleX"] = t.scale[0];
|
|
792
|
+
out["scaleY"] = t.scale[1];
|
|
793
|
+
} else {
|
|
794
|
+
out["scale"] = t.scale;
|
|
795
|
+
}
|
|
247
796
|
}
|
|
248
797
|
if (typeof t.rotate === "number") out["rotate"] = t.rotate;
|
|
249
798
|
if (t.translate !== undefined) {
|
|
@@ -251,6 +800,9 @@ function lowerAnimateState(s: LSMLAnimateState): Record<string, number> {
|
|
|
251
800
|
out["y"] = t.translate[1];
|
|
252
801
|
}
|
|
253
802
|
}
|
|
803
|
+
if (s.filter !== undefined) {
|
|
804
|
+
out["filter"] = lowerFilter(s.filter, nodeId, "animate.from.filter", opts);
|
|
805
|
+
}
|
|
254
806
|
return out;
|
|
255
807
|
}
|
|
256
808
|
|
|
@@ -265,12 +817,29 @@ function compileRepeat(node: LSMLRepeat, opts: CompileOptions): RenderNode {
|
|
|
265
817
|
children: [compiledTemplate],
|
|
266
818
|
};
|
|
267
819
|
if (node.id !== undefined) out.id = node.id;
|
|
820
|
+
// LSML 1.1 §6.7 — per-iteration stagger. Negative values are invalid ;
|
|
821
|
+
// the runtime caps the effective delay (STAGGER_CAP_MS) at render.
|
|
822
|
+
if (node.stagger_ms !== undefined) {
|
|
823
|
+
if (
|
|
824
|
+
typeof node.stagger_ms !== "number" ||
|
|
825
|
+
!Number.isFinite(node.stagger_ms) ||
|
|
826
|
+
node.stagger_ms < 0
|
|
827
|
+
) {
|
|
828
|
+
throw invalid(node.id, "stagger_ms", "must be a finite number >= 0");
|
|
829
|
+
}
|
|
830
|
+
out.stagger_ms = node.stagger_ms;
|
|
831
|
+
}
|
|
268
832
|
return out;
|
|
269
833
|
}
|
|
270
834
|
|
|
271
|
-
function mapTextStyle(node: LSMLText, props: Record<string, unknown
|
|
835
|
+
function mapTextStyle(node: LSMLText, props: Record<string, unknown>, opts: CompileOptions): void {
|
|
272
836
|
if (!node.style) return;
|
|
273
837
|
const s = node.style;
|
|
838
|
+
// Nested consumed-key accounting (issue #34) : a `style.*` key outside
|
|
839
|
+
// the TextStyle grammar (e.g. `style.textShadow`) warns, never drops.
|
|
840
|
+
for (const key of Object.keys(s)) {
|
|
841
|
+
if (!TEXT_STYLE_KEYS.has(key)) warn(opts, node.id, `style.${key}`, NOT_LOWERED);
|
|
842
|
+
}
|
|
274
843
|
// Solar's Text primitive consumes size/weight/colour (UK), not CSS-style names.
|
|
275
844
|
if (s.fontSize !== undefined) props["size"] = s.fontSize;
|
|
276
845
|
if (s.fontFamily !== undefined) props["font"] = s.fontFamily;
|
|
@@ -278,6 +847,12 @@ function mapTextStyle(node: LSMLText, props: Record<string, unknown>): void {
|
|
|
278
847
|
if (s.color !== undefined) props["colour"] = s.color;
|
|
279
848
|
if (s.textAlign !== undefined) props["align"] = mapTextAlign(s.textAlign);
|
|
280
849
|
if (s.lineHeight !== undefined) props["lineHeight"] = s.lineHeight;
|
|
850
|
+
// TextStyle 1.1 typography — same names runtime-side (text.tsx
|
|
851
|
+
// validates each value against the field's grammar before render).
|
|
852
|
+
if (s.letterSpacing !== undefined) props["letterSpacing"] = s.letterSpacing;
|
|
853
|
+
if (s.textTransform !== undefined) props["textTransform"] = s.textTransform;
|
|
854
|
+
if (s.textDecoration !== undefined) props["textDecoration"] = s.textDecoration;
|
|
855
|
+
if (s.fontStyle !== undefined) props["fontStyle"] = s.fontStyle;
|
|
281
856
|
}
|
|
282
857
|
|
|
283
858
|
function mapAlign(a: NonNullable<Extract<LSMLNode, { kind: "stack" }>["align"]>): string {
|
|
@@ -326,14 +901,18 @@ function compileAnimate(a: LSMLAnimateDirective):
|
|
|
326
901
|
duration_ms: number;
|
|
327
902
|
ease?: "linear" | "cubic-in" | "cubic-out" | "cubic-in-out";
|
|
328
903
|
}
|
|
329
|
-
| { kind: "spring"; stiffness?: number; damping?: number }
|
|
904
|
+
| { kind: "spring"; stiffness?: number; damping?: number; mass?: number }
|
|
330
905
|
| undefined {
|
|
331
906
|
const t = a.transition;
|
|
332
907
|
if (!t) return undefined;
|
|
333
908
|
if (t.easing === "spring") {
|
|
334
|
-
const out: { kind: "spring"; stiffness?: number; damping?: number } = {
|
|
909
|
+
const out: { kind: "spring"; stiffness?: number; damping?: number; mass?: number } = {
|
|
910
|
+
kind: "spring",
|
|
911
|
+
};
|
|
335
912
|
if (t.stiffness !== undefined) out.stiffness = t.stiffness;
|
|
336
913
|
if (t.damping !== undefined) out.damping = t.damping;
|
|
914
|
+
// §6.2 — spring mass (default 1 runtime-side, ADR 001 phase B).
|
|
915
|
+
if (t.mass !== undefined) out.mass = t.mass;
|
|
337
916
|
return out;
|
|
338
917
|
}
|
|
339
918
|
return {
|