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