@player-tools/fluent 0.13.0--canary.221.5662

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.
Files changed (121) hide show
  1. package/dist/cjs/index.cjs +2257 -0
  2. package/dist/cjs/index.cjs.map +1 -0
  3. package/dist/index.legacy-esm.js +2143 -0
  4. package/dist/index.mjs +2143 -0
  5. package/dist/index.mjs.map +1 -0
  6. package/package.json +36 -0
  7. package/src/core/base-builder/__tests__/fluent-builder-base.test.ts +2257 -0
  8. package/src/core/base-builder/__tests__/id-generator.test.ts +658 -0
  9. package/src/core/base-builder/__tests__/registry.test.ts +411 -0
  10. package/src/core/base-builder/__tests__/switch.test.ts +501 -0
  11. package/src/core/base-builder/__tests__/template.test.ts +449 -0
  12. package/src/core/base-builder/__tests__/value-extraction.test.ts +200 -0
  13. package/src/core/base-builder/conditional/index.ts +64 -0
  14. package/src/core/base-builder/context.ts +151 -0
  15. package/src/core/base-builder/fluent-builder-base.ts +261 -0
  16. package/src/core/base-builder/guards.ts +137 -0
  17. package/src/core/base-builder/id/generator.ts +286 -0
  18. package/src/core/base-builder/id/registry.ts +152 -0
  19. package/src/core/base-builder/index.ts +60 -0
  20. package/src/core/base-builder/resolution/path-resolver.ts +108 -0
  21. package/src/core/base-builder/resolution/pipeline.ts +96 -0
  22. package/src/core/base-builder/resolution/steps/asset-id.ts +77 -0
  23. package/src/core/base-builder/resolution/steps/asset-wrappers.ts +64 -0
  24. package/src/core/base-builder/resolution/steps/builders.ts +85 -0
  25. package/src/core/base-builder/resolution/steps/mixed-arrays.ts +117 -0
  26. package/src/core/base-builder/resolution/steps/static-values.ts +35 -0
  27. package/src/core/base-builder/resolution/steps/switches.ts +63 -0
  28. package/src/core/base-builder/resolution/steps/templates.ts +30 -0
  29. package/src/core/base-builder/resolution/value-resolver.ts +308 -0
  30. package/src/core/base-builder/storage/auxiliary-storage.ts +82 -0
  31. package/src/core/base-builder/storage/value-storage.ts +280 -0
  32. package/src/core/base-builder/types.ts +184 -0
  33. package/src/core/base-builder/utils.ts +10 -0
  34. package/src/core/flow/__tests__/index.test.ts +292 -0
  35. package/src/core/flow/index.ts +141 -0
  36. package/src/core/index.ts +8 -0
  37. package/src/core/mocks/generated/action.builder.ts +109 -0
  38. package/src/core/mocks/generated/choice.builder.ts +161 -0
  39. package/src/core/mocks/generated/choiceItem.builder.ts +133 -0
  40. package/src/core/mocks/generated/collection.builder.ts +117 -0
  41. package/src/core/mocks/generated/index.ts +7 -0
  42. package/src/core/mocks/generated/info.builder.ts +80 -0
  43. package/src/core/mocks/generated/input.builder.ts +75 -0
  44. package/src/core/mocks/generated/text.builder.ts +63 -0
  45. package/src/core/mocks/index.ts +1 -0
  46. package/src/core/mocks/types/action.ts +92 -0
  47. package/src/core/mocks/types/choice.ts +129 -0
  48. package/src/core/mocks/types/collection.ts +140 -0
  49. package/src/core/mocks/types/info.ts +7 -0
  50. package/src/core/mocks/types/input.ts +7 -0
  51. package/src/core/mocks/types/text.ts +5 -0
  52. package/src/core/schema/__tests__/index.test.ts +127 -0
  53. package/src/core/schema/index.ts +195 -0
  54. package/src/core/schema/types.ts +7 -0
  55. package/src/core/switch/__tests__/index.test.ts +156 -0
  56. package/src/core/switch/index.ts +76 -0
  57. package/src/core/tagged-template/README.md +448 -0
  58. package/src/core/tagged-template/__tests__/extract-bindings-from-schema.test.ts +207 -0
  59. package/src/core/tagged-template/__tests__/index.test.ts +190 -0
  60. package/src/core/tagged-template/__tests__/schema-std-integration.test.ts +580 -0
  61. package/src/core/tagged-template/binding.ts +95 -0
  62. package/src/core/tagged-template/expression.ts +92 -0
  63. package/src/core/tagged-template/extract-bindings-from-schema.ts +120 -0
  64. package/src/core/tagged-template/index.ts +5 -0
  65. package/src/core/tagged-template/std.ts +472 -0
  66. package/src/core/tagged-template/types.ts +123 -0
  67. package/src/core/template/__tests__/index.test.ts +380 -0
  68. package/src/core/template/index.ts +191 -0
  69. package/src/core/utils/index.ts +160 -0
  70. package/src/fp/README.md +411 -0
  71. package/src/fp/__tests__/index.test.ts +1178 -0
  72. package/src/fp/index.ts +386 -0
  73. package/src/gen/common.ts +2 -0
  74. package/src/gen/plugin.mjs +315 -0
  75. package/src/index.ts +5 -0
  76. package/src/types.ts +203 -0
  77. package/types/core/base-builder/conditional/index.d.ts +21 -0
  78. package/types/core/base-builder/context.d.ts +39 -0
  79. package/types/core/base-builder/fluent-builder-base.d.ts +132 -0
  80. package/types/core/base-builder/guards.d.ts +58 -0
  81. package/types/core/base-builder/id/generator.d.ts +69 -0
  82. package/types/core/base-builder/id/registry.d.ts +93 -0
  83. package/types/core/base-builder/index.d.ts +8 -0
  84. package/types/core/base-builder/resolution/path-resolver.d.ts +15 -0
  85. package/types/core/base-builder/resolution/pipeline.d.ts +25 -0
  86. package/types/core/base-builder/resolution/steps/asset-id.d.ts +14 -0
  87. package/types/core/base-builder/resolution/steps/asset-wrappers.d.ts +14 -0
  88. package/types/core/base-builder/resolution/steps/builders.d.ts +14 -0
  89. package/types/core/base-builder/resolution/steps/mixed-arrays.d.ts +14 -0
  90. package/types/core/base-builder/resolution/steps/static-values.d.ts +14 -0
  91. package/types/core/base-builder/resolution/steps/switches.d.ts +15 -0
  92. package/types/core/base-builder/resolution/steps/templates.d.ts +14 -0
  93. package/types/core/base-builder/resolution/value-resolver.d.ts +37 -0
  94. package/types/core/base-builder/storage/auxiliary-storage.d.ts +50 -0
  95. package/types/core/base-builder/storage/value-storage.d.ts +82 -0
  96. package/types/core/base-builder/types.d.ts +141 -0
  97. package/types/core/base-builder/utils.d.ts +2 -0
  98. package/types/core/flow/index.d.ts +23 -0
  99. package/types/core/index.d.ts +8 -0
  100. package/types/core/mocks/index.d.ts +2 -0
  101. package/types/core/mocks/types/action.d.ts +58 -0
  102. package/types/core/mocks/types/choice.d.ts +95 -0
  103. package/types/core/mocks/types/collection.d.ts +102 -0
  104. package/types/core/mocks/types/info.d.ts +7 -0
  105. package/types/core/mocks/types/input.d.ts +7 -0
  106. package/types/core/mocks/types/text.d.ts +5 -0
  107. package/types/core/schema/index.d.ts +34 -0
  108. package/types/core/schema/types.d.ts +5 -0
  109. package/types/core/switch/index.d.ts +21 -0
  110. package/types/core/tagged-template/binding.d.ts +19 -0
  111. package/types/core/tagged-template/expression.d.ts +11 -0
  112. package/types/core/tagged-template/extract-bindings-from-schema.d.ts +7 -0
  113. package/types/core/tagged-template/index.d.ts +6 -0
  114. package/types/core/tagged-template/std.d.ts +174 -0
  115. package/types/core/tagged-template/types.d.ts +69 -0
  116. package/types/core/template/index.d.ts +97 -0
  117. package/types/core/utils/index.d.ts +47 -0
  118. package/types/fp/index.d.ts +149 -0
  119. package/types/gen/common.d.ts +3 -0
  120. package/types/index.d.ts +3 -0
  121. package/types/types.d.ts +163 -0
@@ -0,0 +1,449 @@
1
+ import { describe, test, expect, beforeEach } from "vitest";
2
+ import type { Template, Asset } from "@player-ui/types";
3
+ import { template, isTemplate, TEMPLATE_MARKER } from "../../template";
4
+ import {
5
+ type BaseBuildContext,
6
+ type SlotBranch,
7
+ resetGlobalIdSet,
8
+ } from "../index";
9
+ import { text } from "../../mocks";
10
+ import { binding as b } from "../../tagged-template";
11
+
12
+ // Mock BaseBuildContext
13
+ const mockParentCtx: BaseBuildContext = {
14
+ parentId: "parent-1",
15
+ branch: {
16
+ type: "slot",
17
+ name: "test",
18
+ } as SlotBranch,
19
+ };
20
+
21
+ describe("template integration with base-builder", () => {
22
+ beforeEach(() => {
23
+ resetGlobalIdSet();
24
+ });
25
+
26
+ test("create a basic template configuration", () => {
27
+ const result = template({
28
+ data: "list.of.names",
29
+ output: "values",
30
+ value: text().withValue(b`list.of.names._index_`),
31
+ })(mockParentCtx);
32
+
33
+ const expected: Template<{ asset: Asset<"text"> }> = {
34
+ data: "list.of.names",
35
+ output: "values",
36
+ value: {
37
+ asset: {
38
+ id: "parent-1-test-_index_-text",
39
+ type: "text",
40
+ value: "{{list.of.names._index_}}",
41
+ },
42
+ },
43
+ };
44
+
45
+ expect(result).toEqual(expected);
46
+ });
47
+
48
+ test("create a template with tagged template binding", () => {
49
+ const result = template({
50
+ data: b`list.of.names`,
51
+ output: "values",
52
+ value: text().withValue(b`list.of.names._index_`),
53
+ })(mockParentCtx);
54
+
55
+ const expected: Template<{ asset: Asset<"text"> }> = {
56
+ data: "{{list.of.names}}",
57
+ output: "values",
58
+ value: {
59
+ asset: {
60
+ id: "parent-1-test-_index_-text",
61
+ type: "text",
62
+ value: "{{list.of.names._index_}}",
63
+ },
64
+ },
65
+ };
66
+
67
+ expect(result).toEqual(expected);
68
+ });
69
+
70
+ test("include dynamic flag when specified", () => {
71
+ const result = template({
72
+ data: "list.of.names",
73
+ output: "values",
74
+ dynamic: true,
75
+ value: text().withValue(b`list.of.names._index_`),
76
+ })(mockParentCtx);
77
+
78
+ const expected: Template<{ asset: Asset<"text"> }> = {
79
+ data: "list.of.names",
80
+ output: "values",
81
+ dynamic: true,
82
+ value: {
83
+ asset: {
84
+ id: "parent-1-test-_index_-text",
85
+ type: "text",
86
+ value: "{{list.of.names._index_}}",
87
+ },
88
+ },
89
+ };
90
+
91
+ expect(result).toEqual(expected);
92
+ });
93
+
94
+ test("not include dynamic flag when it is false", () => {
95
+ const result = template({
96
+ data: "list.of.names",
97
+ output: "values",
98
+ dynamic: false,
99
+ value: text().withValue(b`list.of.names._index_`),
100
+ })(mockParentCtx);
101
+
102
+ const expected: Template<{ asset: Asset<"text"> }> = {
103
+ data: "list.of.names",
104
+ output: "values",
105
+ value: {
106
+ asset: {
107
+ id: "parent-1-test-_index_-text",
108
+ type: "text",
109
+ value: "{{list.of.names._index_}}",
110
+ },
111
+ },
112
+ };
113
+
114
+ expect(result).toEqual(expected);
115
+ expect(result).not.toHaveProperty("dynamic");
116
+ });
117
+
118
+ test("pass the correct parent context to the value function", () => {
119
+ let capturedParentCtx: BaseBuildContext | null = null;
120
+
121
+ const valueWithCapture = (parentCtx: BaseBuildContext): Asset<"text"> => {
122
+ capturedParentCtx = parentCtx;
123
+ return {
124
+ id: "test",
125
+ type: "text",
126
+ value: "test",
127
+ };
128
+ };
129
+
130
+ template({
131
+ data: "list.of.names",
132
+ output: "values",
133
+ value: valueWithCapture,
134
+ })(mockParentCtx);
135
+
136
+ expect(capturedParentCtx).toEqual({
137
+ parentId: "parent-1-test",
138
+ branch: {
139
+ type: "template",
140
+ depth: 0,
141
+ },
142
+ });
143
+ });
144
+
145
+ // Simulation of a multiple templates scenario (we test the output structure, not runtime behavior)
146
+ test("support the structure for multiple templates with the same output property", () => {
147
+ const template1 = template({
148
+ data: "list.of.names",
149
+ output: "values",
150
+ value: text().withValue(b`list.of.names._index_`),
151
+ })(mockParentCtx);
152
+
153
+ const template2 = template({
154
+ data: "list.of.other-names",
155
+ output: "values",
156
+ value: text().withValue(b`list.of.other-names._index_`),
157
+ })(mockParentCtx);
158
+
159
+ expect(template1.output).toEqual(template2.output);
160
+ expect(template1.output).toBe("values");
161
+ });
162
+
163
+ // Test complex nested asset structures
164
+ test("handle complex nested asset structures", () => {
165
+ interface CollectionAsset extends Asset<"collection"> {
166
+ items: Array<{ asset: Asset<"text"> }>;
167
+ }
168
+
169
+ const collectionAsset = (): CollectionAsset => ({
170
+ id: "collection-_index_",
171
+ type: "collection",
172
+ items: [
173
+ {
174
+ asset: {
175
+ id: "item-_index_-0",
176
+ type: "text",
177
+ value: "{{list.of.names._index_.first}}",
178
+ },
179
+ },
180
+ {
181
+ asset: {
182
+ id: "item-_index_-1",
183
+ type: "text",
184
+ value: "{{list.of.names._index_.last}}",
185
+ },
186
+ },
187
+ ],
188
+ });
189
+
190
+ const result = template({
191
+ data: "list.of.names",
192
+ output: "collections",
193
+ value: collectionAsset,
194
+ })(mockParentCtx);
195
+
196
+ const expected: Template<{ asset: CollectionAsset }> = {
197
+ data: "list.of.names",
198
+ output: "collections",
199
+ value: {
200
+ asset: {
201
+ id: "collection-_index_",
202
+ type: "collection",
203
+ items: [
204
+ {
205
+ asset: {
206
+ id: "item-_index_-0",
207
+ type: "text",
208
+ value: "{{list.of.names._index_.first}}",
209
+ },
210
+ },
211
+ {
212
+ asset: {
213
+ id: "item-_index_-1",
214
+ type: "text",
215
+ value: "{{list.of.names._index_.last}}",
216
+ },
217
+ },
218
+ ],
219
+ },
220
+ },
221
+ };
222
+
223
+ expect(result).toEqual(expected);
224
+ });
225
+
226
+ // Test the structure for dynamic template functionality
227
+ test("create a dynamic template that updates when data changes", () => {
228
+ const result = template({
229
+ data: "list.of.names",
230
+ output: "values",
231
+ dynamic: true,
232
+ value: text().withValue(b`list.of.names._index_`),
233
+ })(mockParentCtx);
234
+
235
+ expect(result.dynamic).toBe(true);
236
+ });
237
+ });
238
+
239
+ describe("isTemplate type guard", () => {
240
+ beforeEach(() => {
241
+ resetGlobalIdSet();
242
+ });
243
+
244
+ test("should return true for template functions", () => {
245
+ const templateFn = template({
246
+ data: "list.of.names",
247
+ output: "values",
248
+ value: text().withValue(b`list.of.names._index_`),
249
+ });
250
+
251
+ expect(isTemplate(templateFn)).toBe(true);
252
+ });
253
+
254
+ test("should return false for non-template functions", () => {
255
+ const regularFunction = () => ({});
256
+ const arrowFunction = () => "test";
257
+ const objectWithFunction = { fn: () => {} };
258
+
259
+ expect(isTemplate(regularFunction)).toBe(false);
260
+ expect(isTemplate(arrowFunction)).toBe(false);
261
+ expect(isTemplate(objectWithFunction.fn)).toBe(false);
262
+ });
263
+
264
+ test("should return false for non-functions", () => {
265
+ const string = "not a function";
266
+ const number = 42;
267
+ const object = { type: "test" };
268
+ const nullValue = null;
269
+ const undefinedValue = undefined;
270
+
271
+ expect(isTemplate(string)).toBe(false);
272
+ expect(isTemplate(number)).toBe(false);
273
+ expect(isTemplate(object)).toBe(false);
274
+ expect(isTemplate(nullValue)).toBe(false);
275
+ expect(isTemplate(undefinedValue)).toBe(false);
276
+ });
277
+
278
+ test("should have TEMPLATE_MARKER symbol on template functions", () => {
279
+ const templateFn = template({
280
+ data: "list.of.names",
281
+ output: "values",
282
+ value: text().withValue(b`list.of.names._index_`),
283
+ });
284
+
285
+ expect(TEMPLATE_MARKER in templateFn).toBe(true);
286
+ expect(
287
+ (templateFn as unknown as { [TEMPLATE_MARKER]: unknown })[
288
+ TEMPLATE_MARKER
289
+ ],
290
+ ).toBe(true);
291
+ });
292
+
293
+ test("should not have TEMPLATE_MARKER symbol on regular functions", () => {
294
+ const regularFunction = () => ({});
295
+
296
+ expect(TEMPLATE_MARKER in regularFunction).toBe(false);
297
+ });
298
+
299
+ test("should work with template functions that have been called", () => {
300
+ const templateFn = template({
301
+ data: "list.of.names",
302
+ output: "values",
303
+ value: text().withValue(b`list.of.names._index_`),
304
+ });
305
+
306
+ // Call the template function
307
+ templateFn(mockParentCtx);
308
+
309
+ // The original function should still be identifiable as a template
310
+ expect(isTemplate(templateFn)).toBe(true);
311
+ expect(TEMPLATE_MARKER in templateFn).toBe(true);
312
+ });
313
+ });
314
+
315
+ describe("template with optional output", () => {
316
+ beforeEach(() => {
317
+ resetGlobalIdSet();
318
+ });
319
+
320
+ test("should infer output from slot context", () => {
321
+ const contextWithSlot: BaseBuildContext = {
322
+ parentId: "parent-1",
323
+ branch: {
324
+ type: "slot",
325
+ name: "values-0", // This should infer "values" as the output
326
+ },
327
+ };
328
+
329
+ const templateFn = template({
330
+ data: b`list.of.names`,
331
+ // No output provided - should be inferred
332
+ value: text().withValue(b`list.of.names._index_`),
333
+ });
334
+
335
+ const result = templateFn(contextWithSlot);
336
+
337
+ expect(result.output).toBe("values");
338
+ expect(result.data).toBe("{{list.of.names}}");
339
+ });
340
+
341
+ test("should throw error when output cannot be inferred", () => {
342
+ const contextWithoutSlot: BaseBuildContext = {
343
+ parentId: "parent-1",
344
+ branch: {
345
+ type: "template",
346
+ depth: 0,
347
+ },
348
+ };
349
+
350
+ const templateFn = template({
351
+ data: b`list.of.names`,
352
+ // No output provided and context doesn't allow inference
353
+ value: text().withValue(b`list.of.names._index_`),
354
+ });
355
+
356
+ expect(() => templateFn(contextWithoutSlot)).toThrow(
357
+ "Template output must be provided or inferrable from context",
358
+ );
359
+ });
360
+
361
+ test("should use explicit output when provided", () => {
362
+ const contextWithSlot: BaseBuildContext = {
363
+ parentId: "parent-1",
364
+ branch: {
365
+ type: "slot",
366
+ name: "values-0",
367
+ },
368
+ };
369
+
370
+ const templateFn = template({
371
+ data: b`list.of.names`,
372
+ output: "customOutput", // Explicit output should override inference
373
+ value: text().withValue(b`list.of.names._index_`),
374
+ });
375
+
376
+ const result = templateFn(contextWithSlot);
377
+
378
+ expect(result.output).toBe("customOutput");
379
+ });
380
+ });
381
+
382
+ describe("template context creation", () => {
383
+ beforeEach(() => {
384
+ resetGlobalIdSet();
385
+ });
386
+
387
+ test("should create template context with depth 0", () => {
388
+ const context: BaseBuildContext = {
389
+ parentId: "parent",
390
+ };
391
+
392
+ const templateFn = template({
393
+ data: "items",
394
+ output: "values",
395
+ value: (ctx: BaseBuildContext) => {
396
+ // Verify the context has template branch
397
+ expect(ctx.branch).toEqual({ type: "template", depth: 0 });
398
+ expect(ctx.parentId).toBe("parent");
399
+
400
+ return {
401
+ id: "test",
402
+ type: "text",
403
+ value: "test",
404
+ };
405
+ },
406
+ });
407
+
408
+ templateFn(context);
409
+ });
410
+
411
+ test("should generate proper template IDs with _index_ placeholder", () => {
412
+ const context: BaseBuildContext = {
413
+ parentId: "parent",
414
+ };
415
+
416
+ const result = template({
417
+ data: "items",
418
+ output: "values",
419
+ value: text({ value: "test" }),
420
+ })(context);
421
+
422
+ expect(result.value.asset.id).toBe("parent-_index_-text");
423
+ });
424
+
425
+ test("should support nested template depth tracking", () => {
426
+ const context: BaseBuildContext = {
427
+ parentId: "parent",
428
+ };
429
+
430
+ // First level template
431
+ const level1Result = template({
432
+ data: "items",
433
+ output: "values",
434
+ value: text({ value: "test" }),
435
+ })(context);
436
+
437
+ expect(level1Result.value.asset.id).toBe("parent-_index_-text");
438
+
439
+ // Simulate nested template (depth 1)
440
+ const nestedContext: BaseBuildContext = {
441
+ parentId: "parent-item",
442
+ branch: { type: "template", depth: 1 },
443
+ };
444
+
445
+ const nestedAsset = text({ value: "nested" }).build(nestedContext);
446
+
447
+ expect(nestedAsset.id).toBe("parent-item-_index1_-text");
448
+ });
449
+ });
@@ -0,0 +1,200 @@
1
+ import { describe, test, expect, beforeEach } from "vitest";
2
+ import { action } from "../../mocks/generated";
3
+ import { binding as b, expression as e } from "../../tagged-template";
4
+ import { resetGlobalIdSet } from "../index";
5
+
6
+ /**
7
+ * Test suite for value extraction with TaggedTemplateValue
8
+ * Ensures that arrays and objects with TaggedTemplateValue are properly extracted
9
+ * without being stringified to [object Object] or flattened
10
+ */
11
+
12
+ describe("Value Extraction - TaggedTemplateValue Handling", () => {
13
+ beforeEach(() => {
14
+ resetGlobalIdSet();
15
+ });
16
+
17
+ test("preserves array structure with TaggedTemplateValue elements", () => {
18
+ const asset = action()
19
+ .withExp([
20
+ e`conditional(isEmpty({{forms.test}}), setDataVal('forms.test', ''), 'false')`,
21
+ e`conditional(isEmpty({{forms.other}}), setDataVal('forms.other', ''), 'false')`,
22
+ ])
23
+ .build({ parentId: "view-1" });
24
+
25
+ expect(asset.exp).toBeInstanceOf(Array);
26
+ expect(asset.exp).toHaveLength(2);
27
+ // Expressions should preserve @[]@ syntax
28
+ expect(asset.exp?.[0]).toBe(
29
+ "@[conditional(isEmpty({{forms.test}}), setDataVal('forms.test', ''), 'false')]@",
30
+ );
31
+ expect(asset.exp?.[1]).toBe(
32
+ "@[conditional(isEmpty({{forms.other}}), setDataVal('forms.other', ''), 'false')]@",
33
+ );
34
+ });
35
+
36
+ test("preserves array structure with mixed TaggedTemplateValue and strings", () => {
37
+ const asset = action()
38
+ .withExp([
39
+ "static string",
40
+ e`conditional(isEmpty({{forms.test}}), setDataVal('forms.test', ''), 'false')`,
41
+ ])
42
+ .build({ parentId: "view-1" });
43
+
44
+ expect(asset.exp).toBeInstanceOf(Array);
45
+ expect(asset.exp).toHaveLength(2);
46
+ expect(asset.exp?.[0]).toBe("static string");
47
+ // Expression should preserve @[]@ syntax
48
+ expect(asset.exp?.[1]).toBe(
49
+ "@[conditional(isEmpty({{forms.test}}), setDataVal('forms.test', ''), 'false')]@",
50
+ );
51
+ });
52
+
53
+ test("extracts object with TaggedTemplateValue properties correctly", () => {
54
+ const asset = action()
55
+ .withConfirmation({
56
+ message: b`forms.confirmMessage`,
57
+ affirmativeLabel: b`forms.yesLabel`,
58
+ negativeLabel: "Cancel",
59
+ })
60
+ .build({ parentId: "view-1" });
61
+
62
+ // Bindings should preserve {{}} syntax
63
+ expect(asset.confirmation).toEqual({
64
+ message: "{{forms.confirmMessage}}",
65
+ affirmativeLabel: "{{forms.yesLabel}}",
66
+ negativeLabel: "Cancel",
67
+ });
68
+
69
+ // Ensure it's not stringified to [object Object]
70
+ expect(typeof asset.confirmation).toBe("object");
71
+ expect(asset.confirmation).not.toBe("[object Object]");
72
+ });
73
+
74
+ test("handles nested arrays in objects with TaggedTemplateValue", () => {
75
+ // Simulate a complex structure with nested arrays
76
+ const asset = action()
77
+ .withAdditionalProperties({
78
+ listeners: {
79
+ "dataChange.forms.test": [
80
+ e`conditional(isEmpty({{forms.test}}), setDataVal('forms.test', ''), 'false')`,
81
+ ],
82
+ "dataChange.forms.other": [
83
+ e`setDataVal('forms.other', 'value1')`,
84
+ e`setDataVal('forms.other2', 'value2')`,
85
+ ],
86
+ },
87
+ })
88
+ .build({ parentId: "view-1" });
89
+
90
+ // Check listeners object exists and is not stringified
91
+ expect(asset.listeners).toBeDefined();
92
+ expect(typeof asset.listeners).toBe("object");
93
+ expect(asset.listeners).not.toBe("[object Object]");
94
+
95
+ // Check nested arrays are preserved with expression syntax
96
+ const listeners = asset.listeners as Record<string, string[]>;
97
+ expect(Array.isArray(listeners["dataChange.forms.test"])).toBe(true);
98
+ expect(listeners["dataChange.forms.test"]).toHaveLength(1);
99
+ expect(listeners["dataChange.forms.test"][0]).toBe(
100
+ "@[conditional(isEmpty({{forms.test}}), setDataVal('forms.test', ''), 'false')]@",
101
+ );
102
+
103
+ expect(Array.isArray(listeners["dataChange.forms.other"])).toBe(true);
104
+ expect(listeners["dataChange.forms.other"]).toHaveLength(2);
105
+ expect(listeners["dataChange.forms.other"][0]).toBe(
106
+ "@[setDataVal('forms.other', 'value1')]@",
107
+ );
108
+ expect(listeners["dataChange.forms.other"][1]).toBe(
109
+ "@[setDataVal('forms.other2', 'value2')]@",
110
+ );
111
+ });
112
+
113
+ test("handles nested objects with TaggedTemplateValue in arrays", () => {
114
+ // Simulate modifiers array with objects
115
+ const asset = action()
116
+ .withAdditionalProperties({
117
+ modifiers: [
118
+ { type: "tag", value: b`forms.tagValue` },
119
+ { type: "style", value: "important" },
120
+ ],
121
+ })
122
+ .build({ parentId: "view-1" });
123
+
124
+ expect(asset.modifiers).toBeDefined();
125
+ expect(Array.isArray(asset.modifiers)).toBe(true);
126
+
127
+ const modifiers = asset.modifiers as Array<{ type: string; value: string }>;
128
+ expect(modifiers).toHaveLength(2);
129
+
130
+ // First modifier should have extracted TaggedTemplateValue with binding syntax
131
+ expect(modifiers[0]).toEqual({ type: "tag", value: "{{forms.tagValue}}" });
132
+ expect(modifiers[0]).not.toBe("[object Object]");
133
+
134
+ // Second modifier should be unchanged
135
+ expect(modifiers[1]).toEqual({ type: "style", value: "important" });
136
+ expect(modifiers[1]).not.toBe("[object Object]");
137
+ });
138
+
139
+ test("handles empty metadata object correctly", () => {
140
+ const asset = action().withMetaData({}).build({ parentId: "view-1" });
141
+
142
+ expect(asset.metaData).toEqual({});
143
+ expect(asset.metaData).not.toBe("[object Object]");
144
+ });
145
+
146
+ test("handles metadata object with plain values correctly", () => {
147
+ const asset = action()
148
+ .withMetaData({
149
+ role: "primary",
150
+ size: "large",
151
+ })
152
+ .build({ parentId: "view-1" });
153
+
154
+ expect(asset.metaData).toEqual({
155
+ role: "primary",
156
+ size: "large",
157
+ });
158
+ expect(asset.metaData).not.toBe("[object Object]");
159
+ });
160
+
161
+ test("deeply nested structures with TaggedTemplateValue", () => {
162
+ const asset = action()
163
+ .withAdditionalProperties({
164
+ config: {
165
+ validation: {
166
+ rules: [
167
+ e`conditional(isEmpty({{forms.field1}}), 'required', 'valid')`,
168
+ e`conditional(isEmpty({{forms.field2}}), 'required', 'valid')`,
169
+ ],
170
+ messages: {
171
+ error: b`forms.errorMessage`,
172
+ warning: "Please check your input",
173
+ },
174
+ },
175
+ },
176
+ })
177
+ .build({ parentId: "view-1" });
178
+
179
+ const config = asset.config as {
180
+ validation: {
181
+ rules: string[];
182
+ messages: { error: string; warning: string };
183
+ };
184
+ };
185
+
186
+ // Check deeply nested arrays with expression syntax
187
+ expect(Array.isArray(config.validation.rules)).toBe(true);
188
+ expect(config.validation.rules).toHaveLength(2);
189
+ expect(config.validation.rules[0]).toBe(
190
+ "@[conditional(isEmpty({{forms.field1}}), 'required', 'valid')]@",
191
+ );
192
+
193
+ // Check deeply nested objects with binding syntax
194
+ expect(config.validation.messages).toEqual({
195
+ error: "{{forms.errorMessage}}",
196
+ warning: "Please check your input",
197
+ });
198
+ expect(config.validation.messages).not.toBe("[object Object]");
199
+ });
200
+ });
@@ -0,0 +1,64 @@
1
+ import type { Asset } from "@player-ui/types";
2
+ import type { FluentBuilder, BaseBuildContext } from "../types";
3
+ import { isFluentBuilder, isAsset, isAssetWrapperValue } from "../guards";
4
+
5
+ /**
6
+ * Resolves a value or function to its final value
7
+ *
8
+ * Generic helper that unwraps functions to their return values.
9
+ * Handles both simple functions and ConditionalValue types.
10
+ */
11
+ export function resolveValueOrFunction<V>(value: V | (() => V)): V {
12
+ if (typeof value === "function" && !isFluentBuilder(value)) {
13
+ // SAFETY: We've checked it's a function and not a FluentBuilder,
14
+ // so it must be a function returning V
15
+ return (value as () => V)();
16
+ }
17
+
18
+ return value as V;
19
+ }
20
+
21
+ /**
22
+ * Type guard to check if a value should be wrapped in AssetWrapper format
23
+ */
24
+ export function shouldWrapInAssetWrapper(
25
+ value: unknown,
26
+ ): value is
27
+ | FluentBuilder<unknown, BaseBuildContext>
28
+ | Asset
29
+ | Array<FluentBuilder<unknown, BaseBuildContext> | Asset> {
30
+ // Don't wrap if already wrapped (has 'asset' property but not 'type')
31
+ if (isAssetWrapperValue(value)) {
32
+ return false;
33
+ }
34
+
35
+ // Wrap FluentBuilders
36
+ if (isFluentBuilder(value)) {
37
+ return true;
38
+ }
39
+
40
+ // Wrap Assets (objects with 'type' property)
41
+ if (isAsset(value)) {
42
+ return true;
43
+ }
44
+
45
+ // Wrap arrays of builders/assets
46
+ if (Array.isArray(value) && value.length > 0) {
47
+ const firstItem = value[0];
48
+ return isFluentBuilder(firstItem) || isAsset(firstItem);
49
+ }
50
+
51
+ return false;
52
+ }
53
+
54
+ /**
55
+ * Wraps a value in AssetWrapper format if needed
56
+ * This enables if() and ifElse() to work with unwrapped asset builders
57
+ */
58
+ export function maybeWrapAsset<V>(value: V): V | { asset: V } {
59
+ if (shouldWrapInAssetWrapper(value)) {
60
+ return { asset: value };
61
+ }
62
+
63
+ return value;
64
+ }