@lumencast/compiler 0.6.0 → 0.8.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/dist/.tsbuildinfo +1 -1
- package/dist/compile.d.ts +7 -0
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +168 -5
- package/dist/compile.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lsml-1_2.d.ts +36 -0
- package/dist/lsml-1_2.d.ts.map +1 -0
- package/dist/lsml-1_2.js +96 -0
- package/dist/lsml-1_2.js.map +1 -0
- package/dist/lsml-types.d.ts +99 -2
- package/dist/lsml-types.d.ts.map +1 -1
- package/dist/lsml-types.js +7 -0
- package/dist/lsml-types.js.map +1 -1
- package/package.json +3 -3
- package/src/compile.ts +203 -5
- package/src/index.ts +15 -0
- package/src/lsml-1_2.ts +101 -0
- package/src/lsml-types.ts +122 -4
package/src/compile.ts
CHANGED
|
@@ -26,12 +26,22 @@ import type {
|
|
|
26
26
|
LSMLAnimateDirective,
|
|
27
27
|
LSMLAnimateState,
|
|
28
28
|
LSMLBundle,
|
|
29
|
+
LSMLFill,
|
|
29
30
|
LSMLKeyframes,
|
|
31
|
+
LSMLMask,
|
|
30
32
|
LSMLNode,
|
|
31
33
|
LSMLPath,
|
|
32
34
|
LSMLRepeat,
|
|
33
35
|
LSMLText,
|
|
34
36
|
} from "./lsml-types.js";
|
|
37
|
+
import {
|
|
38
|
+
parseBlendMode,
|
|
39
|
+
parseObjectFit,
|
|
40
|
+
clampGradientTransform,
|
|
41
|
+
MASK_TYPES,
|
|
42
|
+
MASK_OPS,
|
|
43
|
+
} from "./lsml-1_2.js";
|
|
44
|
+
import { checkHostAllowed } from "@lumencast/protocol";
|
|
35
45
|
|
|
36
46
|
/** Structured compile diagnostic (ADR 001 §3.4, issue #34). Per
|
|
37
47
|
* Bastion R9 it carries node identity + field + static reason and
|
|
@@ -53,9 +63,16 @@ export interface CompileOptions {
|
|
|
53
63
|
/** Optional warn collector — receives the formatted message plus the
|
|
54
64
|
* structured diagnostic (additive second argument, issue #34). */
|
|
55
65
|
onWarn?: (message: string, diagnostic: CompileDiagnostic) => void;
|
|
66
|
+
/** INTERNAL (ADR 002 #F, Bastion T1/T2) — the bundle's
|
|
67
|
+
* `assets.allowedHosts`, threaded down by `compileBundle` so image-fill
|
|
68
|
+
* `src` is host/scheme-gated at lowering (the compiler arm of the
|
|
69
|
+
* double-gate ; the runtime re-gates because live LSDP deltas bypass the
|
|
70
|
+
* compiler). Not part of the caller-facing contract — `compileBundle`
|
|
71
|
+
* always sets it from the bundle. */
|
|
72
|
+
allowedHosts?: readonly string[];
|
|
56
73
|
}
|
|
57
74
|
|
|
58
|
-
const SUPPORTED_VERSIONS = new Set(["1.0", "1.1"] as const);
|
|
75
|
+
const SUPPORTED_VERSIONS = new Set(["1.0", "1.1", "1.2"] as const);
|
|
59
76
|
|
|
60
77
|
// --- hard caps (ADR 001 §5.1 R8 + §6 RC#10, threat model Bastion) ------
|
|
61
78
|
//
|
|
@@ -132,8 +149,14 @@ const COMMON_NODE_KEYS: ReadonlySet<string> = new Set([
|
|
|
132
149
|
"visible",
|
|
133
150
|
"opacity",
|
|
134
151
|
"rotation",
|
|
152
|
+
"flipY",
|
|
153
|
+
"blur",
|
|
154
|
+
"shadow",
|
|
135
155
|
"sizing",
|
|
136
156
|
"position",
|
|
157
|
+
// 1.2+ (ADR 002 §3.2) — universal blend mode + typed mask on every node.
|
|
158
|
+
"blendMode",
|
|
159
|
+
"mask",
|
|
137
160
|
]);
|
|
138
161
|
|
|
139
162
|
/** Keys `compileRepeat` consumes. `scope` names the iteration scope the
|
|
@@ -151,7 +174,7 @@ const REPEAT_NODE_KEYS: ReadonlySet<string> = new Set([
|
|
|
151
174
|
const KIND_NODE_KEYS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
152
175
|
stack: new Set(["direction", "gap", "align", "justify", "padding", "rtl"]),
|
|
153
176
|
grid: new Set(["columns", "rows", "gap", "padding"]),
|
|
154
|
-
frame: new Set(["size", "position", "background", "backgrounds", "clipsContent"]),
|
|
177
|
+
frame: new Set(["size", "position", "background", "backgrounds", "clipsContent", "cornerRadius"]),
|
|
155
178
|
text: new Set(["style", "format", "maxLines"]),
|
|
156
179
|
image: new Set(["alt", "size", "fit"]),
|
|
157
180
|
shape: new Set([
|
|
@@ -194,6 +217,10 @@ const BUNDLE_KEYS: ReadonlySet<string> = new Set([
|
|
|
194
217
|
"layout",
|
|
195
218
|
"operator_inputs",
|
|
196
219
|
"external_adapters",
|
|
220
|
+
// ADR 002 #F — `assets` (incl. `allowedHosts`, Bastion T1/T6) is now
|
|
221
|
+
// consumed : it drives the image-fill host gate at lowering AND is
|
|
222
|
+
// forwarded verbatim into the RenderBundle so the runtime can re-gate.
|
|
223
|
+
"assets",
|
|
197
224
|
]);
|
|
198
225
|
|
|
199
226
|
const NOT_LOWERED =
|
|
@@ -225,9 +252,17 @@ export function compileBundle(lsml: LSMLBundle, options: CompileOptions = {}): R
|
|
|
225
252
|
);
|
|
226
253
|
}
|
|
227
254
|
auditBundleKeys(lsml, options);
|
|
255
|
+
// ADR 002 #F / Bastion T1+T2+T6 — thread `assets.allowedHosts` down so the
|
|
256
|
+
// compiler arm of the double-gate can host/scheme-check every image-fill
|
|
257
|
+
// `src` at lowering, and FORWARD `assets` verbatim into the bundle so the
|
|
258
|
+
// runtime arm has the allowlist to re-gate live LSDP deltas. `emit_lsml.go`
|
|
259
|
+
// (Orion) likewise preserves `assets.allowedHosts` (T6) — the compiler
|
|
260
|
+
// never fabricates or strips it.
|
|
261
|
+
const opts: CompileOptions = { ...options, allowedHosts: lsml.assets?.allowedHosts };
|
|
228
262
|
return {
|
|
229
263
|
scene_version: lsml.scene_version,
|
|
230
|
-
root: compileNode(lsml.layout,
|
|
264
|
+
root: compileNode(lsml.layout, opts),
|
|
265
|
+
...(lsml.assets !== undefined ? { assets: lsml.assets } : {}),
|
|
231
266
|
// LSML 1.1 §17.3 — forward `profiles[]` verbatim so the runtime applies
|
|
232
267
|
// the same gating rule (§17.3.1 hard rejection for unsupported
|
|
233
268
|
// behavioural profiles, §17.5.1 advisory pass-through for authoring
|
|
@@ -303,10 +338,14 @@ function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
|
|
|
303
338
|
if (node.background !== undefined) props["background"] = node.background;
|
|
304
339
|
// 1.1 §4.3 + §4.12 — stacked backgrounds (frame.tsx reads
|
|
305
340
|
// `resolved.backgrounds`; array form wins over legacy `background`).
|
|
306
|
-
if (node.backgrounds !== undefined)
|
|
341
|
+
if (node.backgrounds !== undefined)
|
|
342
|
+
props["backgrounds"] = lowerFills(node.backgrounds, node.id, "backgrounds", opts);
|
|
307
343
|
// 1.1 §4.3 — clip children to the frame bounds. The spec default
|
|
308
344
|
// (`true`) is applied runtime-side ; only explicit values forward.
|
|
309
345
|
if (node.clipsContent !== undefined) props["clipsContent"] = node.clipsContent;
|
|
346
|
+
// Rounded container (pills, picto square). Canonical RenderNode name is
|
|
347
|
+
// `radius` (frame.tsx reads it as `border-radius`).
|
|
348
|
+
if (node.cornerRadius !== undefined) props["radius"] = node.cornerRadius;
|
|
310
349
|
break;
|
|
311
350
|
|
|
312
351
|
case "text":
|
|
@@ -345,7 +384,7 @@ function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
|
|
|
345
384
|
}
|
|
346
385
|
if (node.fill !== undefined) props["fill"] = node.fill;
|
|
347
386
|
// 1.1 §4.6 + §4.12 — stacked fills (shape.tsx reads `resolved.fills`).
|
|
348
|
-
if (node.fills !== undefined) props["fills"] = node.fills;
|
|
387
|
+
if (node.fills !== undefined) props["fills"] = lowerFills(node.fills, node.id, "fills", opts);
|
|
349
388
|
// Single stroke lowers to the flat props shape.tsx consumes
|
|
350
389
|
// (`stroke` = colour string, `stroke_width` = number). The previous
|
|
351
390
|
// object forward was silently unrenderable.
|
|
@@ -399,6 +438,9 @@ function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
|
|
|
399
438
|
if (node.visible !== undefined) props["visible"] = node.visible;
|
|
400
439
|
if (node.opacity !== undefined) props["opacity"] = node.opacity;
|
|
401
440
|
if (node.rotation !== undefined) props["rotation"] = node.rotation;
|
|
441
|
+
if (node.flipY !== undefined) props["flipY"] = node.flipY;
|
|
442
|
+
if (node.blur !== undefined) props["blur"] = node.blur;
|
|
443
|
+
if (node.shadow !== undefined) props["shadow"] = node.shadow;
|
|
402
444
|
if (node.sizing !== undefined) props["sizing"] = node.sizing;
|
|
403
445
|
if (node.position !== undefined && props["x"] === undefined && props["y"] === undefined) {
|
|
404
446
|
// Frame's case above already sets x/y from `position` ; the universal
|
|
@@ -410,6 +452,22 @@ function compileNode(node: LSMLNode, opts: CompileOptions): RenderNode {
|
|
|
410
452
|
for (const [k, v] of Object.entries(node.bindUniversal)) bindings[k] = v;
|
|
411
453
|
}
|
|
412
454
|
|
|
455
|
+
// 1.2+ universal effects (ADR 002 §3.2 ; Bastion T4). Closed enums :
|
|
456
|
+
// a value outside the allowlist is diagnosed and OMITTED, never
|
|
457
|
+
// forwarded. The runtime re-validates on the live path (#D/#E).
|
|
458
|
+
if (node.blendMode !== undefined) {
|
|
459
|
+
const mode = parseBlendMode(node.blendMode);
|
|
460
|
+
if (mode === null) {
|
|
461
|
+
warn(opts, node.id, "blendMode", "is not a recognised mix-blend-mode (ADR 002 §3.2)");
|
|
462
|
+
} else {
|
|
463
|
+
props["blendMode"] = mode;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (node.mask !== undefined) {
|
|
467
|
+
const mask = lowerMask(node.mask, node.id, opts);
|
|
468
|
+
if (mask !== null) props["mask"] = mask;
|
|
469
|
+
}
|
|
470
|
+
|
|
413
471
|
const children = node.children?.map((c) => compileNode(c, opts));
|
|
414
472
|
|
|
415
473
|
const out: RenderNode = { kind: node.kind };
|
|
@@ -668,6 +726,146 @@ function lowerPaths(
|
|
|
668
726
|
});
|
|
669
727
|
}
|
|
670
728
|
|
|
729
|
+
// --- LSML 1.2 fill + mask lowering (ADR 002 §3.2 ; Bastion T4) ----------
|
|
730
|
+
//
|
|
731
|
+
// Closed-enum + bounded-float gate at lowering. A bad enum value or a
|
|
732
|
+
// malformed gradient transform is diagnosed and the offending FIELD is
|
|
733
|
+
// omitted — the rest of the fill is preserved. Host/scheme allowlist of an
|
|
734
|
+
// image-fill `src` (T1/T2) is enforced HERE (#F) against the threaded
|
|
735
|
+
// `opts.allowedHosts` — the compiler arm of the double-gate. A rejected
|
|
736
|
+
// `src` drops the WHOLE image-fill (never a passthrough URL) with an
|
|
737
|
+
// R9-clean diagnostic. The runtime re-gates because live LSDP deltas bypass
|
|
738
|
+
// the compiler entirely.
|
|
739
|
+
|
|
740
|
+
/** Lower a `fills[]` / `backgrounds[]` array, validating 1.2 image-fill and
|
|
741
|
+
* gradient-transform fields. An image-fill whose `src` fails the host /
|
|
742
|
+
* scheme allowlist is dropped (T1/T2). Unknown fill kinds and stop arrays
|
|
743
|
+
* pass through unchanged (they are gated elsewhere). */
|
|
744
|
+
function lowerFills(
|
|
745
|
+
fills: LSMLFill[],
|
|
746
|
+
nodeId: string | undefined,
|
|
747
|
+
field: string,
|
|
748
|
+
opts: CompileOptions,
|
|
749
|
+
): LSMLFill[] {
|
|
750
|
+
return fills.flatMap((fill, i) => {
|
|
751
|
+
// #L (ADR 002 A2.2) — per-fill `blendMode`, revalidated against the closed
|
|
752
|
+
// enum (T4 compiler arm ; the runtime re-validates independently). A value
|
|
753
|
+
// outside `LSMLBlendMode` is diagnosed + omitted, never passthrough. The
|
|
754
|
+
// helper re-applies the validated mode (or drops it) on whatever the branch
|
|
755
|
+
// below emits, so the same gate covers every Fill variant.
|
|
756
|
+
const applyBlend = <T extends LSMLFill>(out: T): T => {
|
|
757
|
+
if (fill.blendMode === undefined) return out;
|
|
758
|
+
const mode = parseBlendMode(fill.blendMode);
|
|
759
|
+
if (mode === null) {
|
|
760
|
+
warn(
|
|
761
|
+
opts,
|
|
762
|
+
nodeId,
|
|
763
|
+
`${field}[${i}].blendMode`,
|
|
764
|
+
"is not a recognised mix-blend-mode (ADR 002 §3.2)",
|
|
765
|
+
);
|
|
766
|
+
const { blendMode: _drop, ...rest } = out;
|
|
767
|
+
return rest as T;
|
|
768
|
+
}
|
|
769
|
+
return { ...out, blendMode: mode };
|
|
770
|
+
};
|
|
771
|
+
if (fill.kind === "image") {
|
|
772
|
+
// T1/T2 — gate `src` before anything else ; a rejected host/scheme
|
|
773
|
+
// drops the entire image-fill. R9 : the diagnostic carries the static
|
|
774
|
+
// reason, never the URL.
|
|
775
|
+
const decision = checkHostAllowed(fill.src, opts.allowedHosts);
|
|
776
|
+
if (!decision.allowed) {
|
|
777
|
+
warn(
|
|
778
|
+
opts,
|
|
779
|
+
nodeId,
|
|
780
|
+
`${field}[${i}].src`,
|
|
781
|
+
`image-fill src rejected by host/scheme allowlist : ${decision.reason} (ADR 002 §3.2, Bastion T1/T2)`,
|
|
782
|
+
);
|
|
783
|
+
return [];
|
|
784
|
+
}
|
|
785
|
+
const out = { ...fill };
|
|
786
|
+
if (fill.objectFit !== undefined) {
|
|
787
|
+
const fit = parseObjectFit(fill.objectFit);
|
|
788
|
+
if (fit === null) {
|
|
789
|
+
warn(
|
|
790
|
+
opts,
|
|
791
|
+
nodeId,
|
|
792
|
+
`${field}[${i}].objectFit`,
|
|
793
|
+
"is not a recognised object-fit (ADR 002 §3.2)",
|
|
794
|
+
);
|
|
795
|
+
delete out.objectFit;
|
|
796
|
+
} else {
|
|
797
|
+
out.objectFit = fit;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (fill.transform !== undefined) {
|
|
801
|
+
const t = clampGradientTransform(fill.transform);
|
|
802
|
+
if (t === null) {
|
|
803
|
+
warn(opts, nodeId, `${field}[${i}].transform`, "is not 6 finite floats (ADR 002 §3.2)");
|
|
804
|
+
delete out.transform;
|
|
805
|
+
} else {
|
|
806
|
+
out.transform = t;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return applyBlend(out);
|
|
810
|
+
}
|
|
811
|
+
if (fill.kind === "linear-gradient" || fill.kind === "radial-gradient") {
|
|
812
|
+
if (fill.transform === undefined) return applyBlend(fill);
|
|
813
|
+
const t = clampGradientTransform(fill.transform);
|
|
814
|
+
if (t === null) {
|
|
815
|
+
warn(opts, nodeId, `${field}[${i}].transform`, "is not 6 finite floats (ADR 002 §3.2)");
|
|
816
|
+
const { transform: _drop, ...rest } = fill;
|
|
817
|
+
return applyBlend(rest);
|
|
818
|
+
}
|
|
819
|
+
return applyBlend({ ...fill, transform: t });
|
|
820
|
+
}
|
|
821
|
+
return applyBlend(fill);
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Lower a typed `mask` (ADR 002 §3.2). Closed enums on `type` / `op` ; a bad
|
|
826
|
+
* enum or a malformed `source` discriminant drops the whole mask (it cannot
|
|
827
|
+
* be partially honoured). NEVER forwards a free SVG string — `source` is a
|
|
828
|
+
* typed discriminated union by construction (Bastion T3). */
|
|
829
|
+
function lowerMask(
|
|
830
|
+
mask: LSMLMask,
|
|
831
|
+
nodeId: string | undefined,
|
|
832
|
+
opts: CompileOptions,
|
|
833
|
+
): LSMLMask | null {
|
|
834
|
+
if (typeof mask !== "object" || mask === null) {
|
|
835
|
+
warn(opts, nodeId, "mask", "is not a mask object (ADR 002 §3.2)");
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
if (!MASK_TYPES.has(mask.type as string)) {
|
|
839
|
+
warn(opts, nodeId, "mask.type", "is not alpha|luminance (ADR 002 §3.2)");
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
if (!MASK_OPS.has(mask.op as string)) {
|
|
843
|
+
warn(opts, nodeId, "mask.op", "is not intersect|subtract|union (ADR 002 §3.2)");
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
const src = mask.source;
|
|
847
|
+
const validSource =
|
|
848
|
+
typeof src === "object" &&
|
|
849
|
+
src !== null &&
|
|
850
|
+
((src.kind === "shape" && typeof (src as { ref?: unknown }).ref === "string") ||
|
|
851
|
+
(src.kind === "image" && typeof (src as { src?: unknown }).src === "string") ||
|
|
852
|
+
// group/frame container source (ADR 002 A4.3) — same closed-enum, same
|
|
853
|
+
// `ref` shape as `shape` ; the runtime composites its visible children.
|
|
854
|
+
(src.kind === "group" && typeof (src as { ref?: unknown }).ref === "string"));
|
|
855
|
+
if (!validSource) {
|
|
856
|
+
warn(opts, nodeId, "mask.source", "is not a typed shape|image|group source (ADR 002 §3.2)");
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
// Re-emit only the typed fields, dropping any extraneous keys.
|
|
860
|
+
return {
|
|
861
|
+
source: src,
|
|
862
|
+
type: mask.type,
|
|
863
|
+
op: mask.op,
|
|
864
|
+
...(mask.position !== undefined ? { position: mask.position } : {}),
|
|
865
|
+
...(mask.size !== undefined ? { size: mask.size } : {}),
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
671
869
|
// --- filter lowering (ADR 001 §5.1 R8 — hard clamps, non-optional) -----
|
|
672
870
|
|
|
673
871
|
/** Lower an LSML `filter` state (`{ blur?, brightness? }`) to the CSS
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,17 @@ export {
|
|
|
14
14
|
type CompileDiagnostic,
|
|
15
15
|
} from "./compile.js";
|
|
16
16
|
export { canonicalize, hashBundle, ZERO_HASH } from "./canonicalize.js";
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
BLEND_MODES,
|
|
20
|
+
OBJECT_FITS,
|
|
21
|
+
MASK_TYPES,
|
|
22
|
+
MASK_OPS,
|
|
23
|
+
MAX_GRADIENT_TRANSFORM_ABS,
|
|
24
|
+
parseBlendMode,
|
|
25
|
+
parseObjectFit,
|
|
26
|
+
clampGradientTransform,
|
|
27
|
+
} from "./lsml-1_2.js";
|
|
17
28
|
export type {
|
|
18
29
|
LSMLBundle,
|
|
19
30
|
LSMLNode,
|
|
@@ -22,6 +33,10 @@ export type {
|
|
|
22
33
|
LSMLAnimateDirective,
|
|
23
34
|
LSMLFill,
|
|
24
35
|
LSMLFillStop,
|
|
36
|
+
LSMLBlendMode,
|
|
37
|
+
LSMLObjectFit,
|
|
38
|
+
LSMLMask,
|
|
39
|
+
LSMLGradientTransform,
|
|
25
40
|
LSMLStroke,
|
|
26
41
|
LSMLPath,
|
|
27
42
|
LSMLKeyframes,
|
package/src/lsml-1_2.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// LSML 1.2 closed-enum parsers + bounded gradient-transform clamp
|
|
2
|
+
// (ADR 002 #C ; Bastion condition T4). Compiler half of the double-gate :
|
|
3
|
+
// the runtime re-validates the same values on the live path (#D/#E/#F wire
|
|
4
|
+
// `mix-blend-mode`/`object-fit`/`mask`/`gradientTransform` at render). This
|
|
5
|
+
// module mirrors the `css-color.ts` / `filter-clamp.ts` contract :
|
|
6
|
+
//
|
|
7
|
+
// - a value outside the closed enum returns `null` → caller emits a
|
|
8
|
+
// diagnostic and OMITS the field. NEVER passthrough of the raw value.
|
|
9
|
+
// - a gradient transform is 6 finite floats, each clamped to a bounded
|
|
10
|
+
// range. A malformed transform returns `null` → omitted, never a free
|
|
11
|
+
// string interpolated into SVG.
|
|
12
|
+
//
|
|
13
|
+
// None of these helpers throws, logs, or echoes the offending value — the
|
|
14
|
+
// caller attaches a static reason to a diagnostic (Bastion R9).
|
|
15
|
+
|
|
16
|
+
import type { LSMLBlendMode, LSMLObjectFit, LSMLGradientTransform } from "./lsml-types.js";
|
|
17
|
+
|
|
18
|
+
/** Closed `mix-blend-mode` allowlist (ADR 002 §3.2 — Figma minus
|
|
19
|
+
* `PASS_THROUGH`). The single source of truth for the compiler. */
|
|
20
|
+
export const BLEND_MODES: ReadonlySet<LSMLBlendMode> = new Set([
|
|
21
|
+
"normal",
|
|
22
|
+
"multiply",
|
|
23
|
+
"screen",
|
|
24
|
+
"overlay",
|
|
25
|
+
"darken",
|
|
26
|
+
"lighten",
|
|
27
|
+
"color-dodge",
|
|
28
|
+
"color-burn",
|
|
29
|
+
"hard-light",
|
|
30
|
+
"soft-light",
|
|
31
|
+
"difference",
|
|
32
|
+
"exclusion",
|
|
33
|
+
"hue",
|
|
34
|
+
"saturation",
|
|
35
|
+
"color",
|
|
36
|
+
"luminosity",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/** Closed `object-fit` allowlist (ADR 002 §3.2). */
|
|
40
|
+
export const OBJECT_FITS: ReadonlySet<LSMLObjectFit> = new Set([
|
|
41
|
+
"cover",
|
|
42
|
+
"contain",
|
|
43
|
+
"fill",
|
|
44
|
+
"none",
|
|
45
|
+
"scale-down",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** Closed `mask.type` allowlist. */
|
|
49
|
+
export const MASK_TYPES: ReadonlySet<string> = new Set(["alpha", "luminance"]);
|
|
50
|
+
|
|
51
|
+
/** Closed `mask.op` allowlist. */
|
|
52
|
+
export const MASK_OPS: ReadonlySet<string> = new Set(["intersect", "subtract", "union"]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate a `blendMode` against the closed enum. Returns the value when it
|
|
56
|
+
* is a recognised mode, else `null` (caller omits + diagnoses). Never
|
|
57
|
+
* passthrough.
|
|
58
|
+
*/
|
|
59
|
+
export function parseBlendMode(value: unknown): LSMLBlendMode | null {
|
|
60
|
+
return typeof value === "string" && BLEND_MODES.has(value as LSMLBlendMode)
|
|
61
|
+
? (value as LSMLBlendMode)
|
|
62
|
+
: null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validate an `objectFit` against the closed enum. Returns the value or
|
|
67
|
+
* `null` (caller omits + diagnoses). Never passthrough.
|
|
68
|
+
*/
|
|
69
|
+
export function parseObjectFit(value: unknown): LSMLObjectFit | null {
|
|
70
|
+
return typeof value === "string" && OBJECT_FITS.has(value as LSMLObjectFit)
|
|
71
|
+
? (value as LSMLObjectFit)
|
|
72
|
+
: null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Bound on each affine component (anti-DoS ; a gradient transform is purely
|
|
76
|
+
* cosmetic, no legitimate value approaches this). Mirrors the spirit of the
|
|
77
|
+
* filter caps : finite and bounded, never a free string. */
|
|
78
|
+
export const MAX_GRADIENT_TRANSFORM_ABS = 1e6;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Validate + clamp a gradient `transform` to 6 finite, bounded floats. The
|
|
82
|
+
* input must be an array of exactly 6 numbers ; each non-finite or
|
|
83
|
+
* out-of-range component fails the whole transform (returns `null` → omit,
|
|
84
|
+
* fall back to `angle_deg`). Components within range are clamped to
|
|
85
|
+
* `[-MAX_GRADIENT_TRANSFORM_ABS, +MAX_GRADIENT_TRANSFORM_ABS]`. A `-0`
|
|
86
|
+
* normalises to `0`. NEVER returns a string ; SVG `gradientTransform` is
|
|
87
|
+
* built numerically by the runtime (#D), never interpolated from author text.
|
|
88
|
+
*/
|
|
89
|
+
export function clampGradientTransform(value: unknown): LSMLGradientTransform | null {
|
|
90
|
+
if (!Array.isArray(value) || value.length !== 6) return null;
|
|
91
|
+
const out = new Array<number>(6);
|
|
92
|
+
for (let i = 0; i < 6; i++) {
|
|
93
|
+
const c = value[i];
|
|
94
|
+
if (typeof c !== "number" || !Number.isFinite(c)) return null;
|
|
95
|
+
let clamped = c;
|
|
96
|
+
if (clamped > MAX_GRADIENT_TRANSFORM_ABS) clamped = MAX_GRADIENT_TRANSFORM_ABS;
|
|
97
|
+
else if (clamped < -MAX_GRADIENT_TRANSFORM_ABS) clamped = -MAX_GRADIENT_TRANSFORM_ABS;
|
|
98
|
+
out[i] = Object.is(clamped, -0) ? 0 : clamped;
|
|
99
|
+
}
|
|
100
|
+
return out as LSMLGradientTransform;
|
|
101
|
+
}
|
package/src/lsml-types.ts
CHANGED
|
@@ -9,6 +9,69 @@
|
|
|
9
9
|
// - Multi-fill `fills[]` on `shape` (§4.6 + §4.12)
|
|
10
10
|
// - Stacked `backgrounds[]` on `frame` (§4.3)
|
|
11
11
|
// - Bundle-level `$schema`, `profiles[]` (§17.3)
|
|
12
|
+
//
|
|
13
|
+
// 1.2 additions (additive over 1.1 — a 1.1 bundle stays valid ; ADR 002 §3.2) :
|
|
14
|
+
// - `blendMode` on every primitive (closed enum → CSS `mix-blend-mode`)
|
|
15
|
+
// - `mask` on every primitive (typed fields, never a free SVG string)
|
|
16
|
+
// - first-class image-fill variant on `LSMLFill` (`{ kind: "image"; … }`)
|
|
17
|
+
// with a closed `objectFit` enum
|
|
18
|
+
// - gradient `transform` (6 finite, bounded floats — never a free string)
|
|
19
|
+
|
|
20
|
+
/** 1.2+ — CSS `mix-blend-mode` value, restricted to the closed set faithful
|
|
21
|
+
* to Figma minus `PASS_THROUGH` (ADR 002 §3.2 ; Bastion T4). A value outside
|
|
22
|
+
* this set is a diagnostic + omission at the compiler, never passthrough. */
|
|
23
|
+
export type LSMLBlendMode =
|
|
24
|
+
| "normal"
|
|
25
|
+
| "multiply"
|
|
26
|
+
| "screen"
|
|
27
|
+
| "overlay"
|
|
28
|
+
| "darken"
|
|
29
|
+
| "lighten"
|
|
30
|
+
| "color-dodge"
|
|
31
|
+
| "color-burn"
|
|
32
|
+
| "hard-light"
|
|
33
|
+
| "soft-light"
|
|
34
|
+
| "difference"
|
|
35
|
+
| "exclusion"
|
|
36
|
+
| "hue"
|
|
37
|
+
| "saturation"
|
|
38
|
+
| "color"
|
|
39
|
+
| "luminosity"
|
|
40
|
+
| "plus-lighter";
|
|
41
|
+
|
|
42
|
+
/** 1.2+ — how an image-fill / image source is fitted into its box
|
|
43
|
+
* (closed enum → CSS `object-fit` ; ADR 002 §3.2 ; Bastion T4). */
|
|
44
|
+
export type LSMLObjectFit = "cover" | "contain" | "fill" | "none" | "scale-down";
|
|
45
|
+
|
|
46
|
+
/** 1.2+ — masking model (LSML §4.x, ADR 002 §3.2). A node carries a typed
|
|
47
|
+
* `mask` whose fields are ALL typed — there is deliberately NO free-form SVG
|
|
48
|
+
* string anywhere in this shape (Bastion T3 : the runtime builds `<mask>` /
|
|
49
|
+
* `<clipPath>` from these fields, never from author markup). */
|
|
50
|
+
export interface LSMLMask {
|
|
51
|
+
/** What provides the mask coverage. A reference to a sibling shape (by id),
|
|
52
|
+
* an image asset URL, or a reference to a sibling GROUP/FRAME container (by
|
|
53
|
+
* id) whose visible children's geometry is composited (ADR 002 A4.3). A
|
|
54
|
+
* `kind: "image"` source is re-gated by the host/scheme allowlist (T1/T2)
|
|
55
|
+
* before it reaches the DOM. */
|
|
56
|
+
source:
|
|
57
|
+
| { kind: "shape"; ref: string }
|
|
58
|
+
| { kind: "image"; src: string }
|
|
59
|
+
| { kind: "group"; ref: string };
|
|
60
|
+
/** Whether the mask reads the source's alpha channel or its luminance. */
|
|
61
|
+
type: "alpha" | "luminance";
|
|
62
|
+
/** Boolean composition op against the masked content. */
|
|
63
|
+
op: "intersect" | "subtract" | "union";
|
|
64
|
+
/** Optional placement of the mask source within the masked box. */
|
|
65
|
+
position?: { x: number; y: number };
|
|
66
|
+
/** Optional explicit size of the mask source. */
|
|
67
|
+
size?: { w: number; h: number };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 1.2+ — a gradient `transform` : the 6 floats of an affine 2×3 matrix
|
|
71
|
+
* `[a, b, c, d, e, f]` (ADR 002 §3.2 ; Bastion T4). Carried as typed
|
|
72
|
+
* numbers, never a free string ; the compiler clamps each component to a
|
|
73
|
+
* finite, bounded value before it reaches `gradientTransform` SVG. */
|
|
74
|
+
export type LSMLGradientTransform = [number, number, number, number, number, number];
|
|
12
75
|
|
|
13
76
|
export type LSMLPrimitiveKind =
|
|
14
77
|
| "stack"
|
|
@@ -38,16 +101,46 @@ export interface LSMLFillStop {
|
|
|
38
101
|
}
|
|
39
102
|
|
|
40
103
|
/** 1.1+ — Fill union used by `shape.fills[]` and `frame.backgrounds[]`
|
|
41
|
-
* (LSML §4.12). Discriminated on `kind`.
|
|
104
|
+
* (LSML §4.12). Discriminated on `kind`.
|
|
105
|
+
*
|
|
106
|
+
* 1.2+ (#L) — each variant may carry an optional `blendMode` (the closed
|
|
107
|
+
* `LSMLBlendMode` enum, no new value introduced) applied as a per-fill-layer
|
|
108
|
+
* `mix-blend-mode`, independent of the node-level blend (`LSMLBaseNode.
|
|
109
|
+
* blendMode`, #D). Absent = `normal` (rétro-compat : a pre-#L bundle is
|
|
110
|
+
* unchanged). Re-validated against the closed enum by both the compiler and
|
|
111
|
+
* the runtime (T4 double-gate) ; out-of-enum → omission, never passthrough. */
|
|
42
112
|
export type LSMLFill =
|
|
43
|
-
| { kind: "solid"; color: string; opacity?: number }
|
|
44
|
-
| {
|
|
113
|
+
| { kind: "solid"; color: string; opacity?: number; blendMode?: LSMLBlendMode }
|
|
114
|
+
| {
|
|
115
|
+
kind: "linear-gradient";
|
|
116
|
+
angle_deg?: number;
|
|
117
|
+
/** 1.2+ — full affine gradient transform (6 floats). When present it
|
|
118
|
+
* supersedes `angle_deg` (ADR 002 §3.2). */
|
|
119
|
+
transform?: LSMLGradientTransform;
|
|
120
|
+
stops: LSMLFillStop[];
|
|
121
|
+
opacity?: number;
|
|
122
|
+
blendMode?: LSMLBlendMode;
|
|
123
|
+
}
|
|
45
124
|
| {
|
|
46
125
|
kind: "radial-gradient";
|
|
47
126
|
center?: { x: number; y: number };
|
|
48
127
|
radius?: number;
|
|
128
|
+
/** 1.2+ — full affine gradient transform (6 floats). */
|
|
129
|
+
transform?: LSMLGradientTransform;
|
|
49
130
|
stops: LSMLFillStop[];
|
|
50
131
|
opacity?: number;
|
|
132
|
+
blendMode?: LSMLBlendMode;
|
|
133
|
+
}
|
|
134
|
+
| {
|
|
135
|
+
/** 1.2+ — first-class image-fill (ADR 002 §3.2). Unifies the frame
|
|
136
|
+
* image-background and unblocks the shape image-fill that 1.1 dropped.
|
|
137
|
+
* `src` is host/scheme-allowlist-gated (T1/T2) before the DOM. */
|
|
138
|
+
kind: "image";
|
|
139
|
+
src: string;
|
|
140
|
+
objectFit?: LSMLObjectFit;
|
|
141
|
+
opacity?: number;
|
|
142
|
+
transform?: LSMLGradientTransform;
|
|
143
|
+
blendMode?: LSMLBlendMode;
|
|
51
144
|
};
|
|
52
145
|
|
|
53
146
|
/** 1.1+ — one stacked stroke layer (LSML §4.6). */
|
|
@@ -147,10 +240,32 @@ export interface LSMLBaseNode {
|
|
|
147
240
|
opacity?: number;
|
|
148
241
|
/** 1.1+ — rotation in degrees (LSML §5.4). Defaults to 0. */
|
|
149
242
|
rotation?: number;
|
|
243
|
+
/** Mirror the node vertically (`scaleY(-1)`) — from a negative Figma
|
|
244
|
+
* transform determinant. Composed with `rotation` at render. */
|
|
245
|
+
flipY?: boolean;
|
|
246
|
+
/** Figma LAYER_BLUR radius (px) → CSS `filter: blur()`. */
|
|
247
|
+
blur?: number;
|
|
248
|
+
/** Figma DROP_SHADOW / INNER_SHADOW → CSS `box-shadow` / `filter: drop-shadow`.
|
|
249
|
+
* Each layer is `{ inset?, color, x, y, blur, spread }` (colour strict-parsed
|
|
250
|
+
* runtime-side, RC#11). The picto square's depth halo + orange/red inner rim. */
|
|
251
|
+
shadow?: Array<{
|
|
252
|
+
inset?: boolean;
|
|
253
|
+
color: string;
|
|
254
|
+
x: number;
|
|
255
|
+
y: number;
|
|
256
|
+
blur: number;
|
|
257
|
+
spread: number;
|
|
258
|
+
}>;
|
|
150
259
|
/** 1.1+ — per-axis sizing mode (LSML §5.4). */
|
|
151
260
|
sizing?: { x?: "fixed" | "hug" | "fill"; y?: "fixed" | "hug" | "fill" };
|
|
152
261
|
/** 1.1+ — universal position relative to parent (LSML §5.4). */
|
|
153
262
|
position?: { x: number; y: number };
|
|
263
|
+
/** 1.2+ — CSS `mix-blend-mode` (closed enum ; ADR 002 §3.2). A value
|
|
264
|
+
* outside `LSMLBlendMode` is a diagnostic + omission, never passthrough. */
|
|
265
|
+
blendMode?: LSMLBlendMode;
|
|
266
|
+
/** 1.2+ — typed mask spec (ADR 002 §3.2). Built into `<mask>`/`<clipPath>`
|
|
267
|
+
* by the runtime from typed fields — never from author SVG markup. */
|
|
268
|
+
mask?: LSMLMask;
|
|
154
269
|
/** Open-ended authoring metadata (LSML §17.4). Runtime ignores. */
|
|
155
270
|
metadata?: Record<string, unknown>;
|
|
156
271
|
}
|
|
@@ -186,6 +301,9 @@ export interface LSMLFrame extends LSMLBaseNode {
|
|
|
186
301
|
* default is `true` ; the default is runtime-side, the compiler only
|
|
187
302
|
* forwards an explicit value. */
|
|
188
303
|
clipsContent?: boolean;
|
|
304
|
+
/** Figma `cornerRadius` (px) → canonical RenderNode `radius` (border-radius).
|
|
305
|
+
* The rounded picto square (r=111). */
|
|
306
|
+
cornerRadius?: number;
|
|
189
307
|
}
|
|
190
308
|
|
|
191
309
|
export interface LSMLText extends LSMLBaseNode {
|
|
@@ -290,7 +408,7 @@ export interface LSMLOperatorInput {
|
|
|
290
408
|
}
|
|
291
409
|
|
|
292
410
|
export interface LSMLBundle {
|
|
293
|
-
lsml: "1.0" | "1.1";
|
|
411
|
+
lsml: "1.0" | "1.1" | "1.2";
|
|
294
412
|
/** 1.1+ — informational schema URL for editor autocomplete (LSML §18.4). */
|
|
295
413
|
$schema?: string;
|
|
296
414
|
scene_id: string;
|