@player-tools/fluent 0.12.1--canary.241.6077

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 (134) hide show
  1. package/dist/cjs/index.cjs +2396 -0
  2. package/dist/cjs/index.cjs.map +1 -0
  3. package/dist/index.legacy-esm.js +2276 -0
  4. package/dist/index.mjs +2276 -0
  5. package/dist/index.mjs.map +1 -0
  6. package/package.json +38 -0
  7. package/src/core/base-builder/__tests__/fluent-builder-base.test.ts +2423 -0
  8. package/src/core/base-builder/__tests__/fluent-partial.test.ts +179 -0
  9. package/src/core/base-builder/__tests__/id-generator.test.ts +658 -0
  10. package/src/core/base-builder/__tests__/registry.test.ts +534 -0
  11. package/src/core/base-builder/__tests__/resolution-mixed-arrays.test.ts +319 -0
  12. package/src/core/base-builder/__tests__/resolution-pipeline.test.ts +416 -0
  13. package/src/core/base-builder/__tests__/resolution-switches.test.ts +468 -0
  14. package/src/core/base-builder/__tests__/resolution-templates.test.ts +255 -0
  15. package/src/core/base-builder/__tests__/switch.test.ts +815 -0
  16. package/src/core/base-builder/__tests__/template.test.ts +596 -0
  17. package/src/core/base-builder/__tests__/value-extraction.test.ts +200 -0
  18. package/src/core/base-builder/__tests__/value-storage.test.ts +459 -0
  19. package/src/core/base-builder/conditional/index.ts +64 -0
  20. package/src/core/base-builder/context.ts +152 -0
  21. package/src/core/base-builder/errors.ts +69 -0
  22. package/src/core/base-builder/fluent-builder-base.ts +308 -0
  23. package/src/core/base-builder/guards.ts +137 -0
  24. package/src/core/base-builder/id/generator.ts +290 -0
  25. package/src/core/base-builder/id/registry.ts +152 -0
  26. package/src/core/base-builder/index.ts +72 -0
  27. package/src/core/base-builder/resolution/path-resolver.ts +116 -0
  28. package/src/core/base-builder/resolution/pipeline.ts +103 -0
  29. package/src/core/base-builder/resolution/steps/__tests__/nested-asset-wrappers.test.ts +206 -0
  30. package/src/core/base-builder/resolution/steps/asset-id.ts +77 -0
  31. package/src/core/base-builder/resolution/steps/asset-wrappers.ts +64 -0
  32. package/src/core/base-builder/resolution/steps/builders.ts +84 -0
  33. package/src/core/base-builder/resolution/steps/mixed-arrays.ts +95 -0
  34. package/src/core/base-builder/resolution/steps/nested-asset-wrappers.ts +124 -0
  35. package/src/core/base-builder/resolution/steps/static-values.ts +35 -0
  36. package/src/core/base-builder/resolution/steps/switches.ts +71 -0
  37. package/src/core/base-builder/resolution/steps/templates.ts +40 -0
  38. package/src/core/base-builder/resolution/value-resolver.ts +333 -0
  39. package/src/core/base-builder/storage/auxiliary-storage.ts +82 -0
  40. package/src/core/base-builder/storage/value-storage.ts +282 -0
  41. package/src/core/base-builder/types.ts +266 -0
  42. package/src/core/base-builder/utils.ts +10 -0
  43. package/src/core/flow/__tests__/index.test.ts +292 -0
  44. package/src/core/flow/index.ts +118 -0
  45. package/src/core/index.ts +8 -0
  46. package/src/core/mocks/generated/action.builder.ts +92 -0
  47. package/src/core/mocks/generated/choice-item.builder.ts +120 -0
  48. package/src/core/mocks/generated/choice.builder.ts +134 -0
  49. package/src/core/mocks/generated/collection.builder.ts +93 -0
  50. package/src/core/mocks/generated/field-collection.builder.ts +86 -0
  51. package/src/core/mocks/generated/index.ts +10 -0
  52. package/src/core/mocks/generated/info.builder.ts +64 -0
  53. package/src/core/mocks/generated/input.builder.ts +63 -0
  54. package/src/core/mocks/generated/overview-collection.builder.ts +65 -0
  55. package/src/core/mocks/generated/splash-collection.builder.ts +93 -0
  56. package/src/core/mocks/generated/text.builder.ts +47 -0
  57. package/src/core/mocks/index.ts +1 -0
  58. package/src/core/mocks/types/action.ts +92 -0
  59. package/src/core/mocks/types/choice.ts +129 -0
  60. package/src/core/mocks/types/collection.ts +140 -0
  61. package/src/core/mocks/types/info.ts +7 -0
  62. package/src/core/mocks/types/input.ts +7 -0
  63. package/src/core/mocks/types/text.ts +5 -0
  64. package/src/core/schema/__tests__/index.test.ts +127 -0
  65. package/src/core/schema/index.ts +195 -0
  66. package/src/core/schema/types.ts +7 -0
  67. package/src/core/switch/__tests__/index.test.ts +156 -0
  68. package/src/core/switch/index.ts +81 -0
  69. package/src/core/tagged-template/README.md +448 -0
  70. package/src/core/tagged-template/__tests__/extract-bindings-from-schema.test.ts +207 -0
  71. package/src/core/tagged-template/__tests__/index.test.ts +190 -0
  72. package/src/core/tagged-template/__tests__/schema-std-integration.test.ts +580 -0
  73. package/src/core/tagged-template/binding.ts +95 -0
  74. package/src/core/tagged-template/expression.ts +92 -0
  75. package/src/core/tagged-template/extract-bindings-from-schema.ts +120 -0
  76. package/src/core/tagged-template/index.ts +5 -0
  77. package/src/core/tagged-template/std.ts +472 -0
  78. package/src/core/tagged-template/types.ts +123 -0
  79. package/src/core/template/__tests__/index.test.ts +380 -0
  80. package/src/core/template/index.ts +196 -0
  81. package/src/core/utils/index.ts +160 -0
  82. package/src/fp/README.md +411 -0
  83. package/src/fp/__tests__/index.test.ts +1178 -0
  84. package/src/fp/index.ts +386 -0
  85. package/src/gen/common.ts +15 -0
  86. package/src/index.ts +5 -0
  87. package/src/types.ts +203 -0
  88. package/types/core/base-builder/conditional/index.d.ts +21 -0
  89. package/types/core/base-builder/context.d.ts +39 -0
  90. package/types/core/base-builder/errors.d.ts +45 -0
  91. package/types/core/base-builder/fluent-builder-base.d.ts +147 -0
  92. package/types/core/base-builder/guards.d.ts +58 -0
  93. package/types/core/base-builder/id/generator.d.ts +69 -0
  94. package/types/core/base-builder/id/registry.d.ts +93 -0
  95. package/types/core/base-builder/index.d.ts +9 -0
  96. package/types/core/base-builder/resolution/path-resolver.d.ts +15 -0
  97. package/types/core/base-builder/resolution/pipeline.d.ts +27 -0
  98. package/types/core/base-builder/resolution/steps/asset-id.d.ts +14 -0
  99. package/types/core/base-builder/resolution/steps/asset-wrappers.d.ts +14 -0
  100. package/types/core/base-builder/resolution/steps/builders.d.ts +14 -0
  101. package/types/core/base-builder/resolution/steps/mixed-arrays.d.ts +14 -0
  102. package/types/core/base-builder/resolution/steps/nested-asset-wrappers.d.ts +14 -0
  103. package/types/core/base-builder/resolution/steps/static-values.d.ts +14 -0
  104. package/types/core/base-builder/resolution/steps/switches.d.ts +15 -0
  105. package/types/core/base-builder/resolution/steps/templates.d.ts +14 -0
  106. package/types/core/base-builder/resolution/value-resolver.d.ts +62 -0
  107. package/types/core/base-builder/storage/auxiliary-storage.d.ts +50 -0
  108. package/types/core/base-builder/storage/value-storage.d.ts +82 -0
  109. package/types/core/base-builder/types.d.ts +183 -0
  110. package/types/core/base-builder/utils.d.ts +2 -0
  111. package/types/core/flow/index.d.ts +23 -0
  112. package/types/core/index.d.ts +8 -0
  113. package/types/core/mocks/index.d.ts +2 -0
  114. package/types/core/mocks/types/action.d.ts +58 -0
  115. package/types/core/mocks/types/choice.d.ts +95 -0
  116. package/types/core/mocks/types/collection.d.ts +102 -0
  117. package/types/core/mocks/types/info.d.ts +7 -0
  118. package/types/core/mocks/types/input.d.ts +7 -0
  119. package/types/core/mocks/types/text.d.ts +5 -0
  120. package/types/core/schema/index.d.ts +34 -0
  121. package/types/core/schema/types.d.ts +5 -0
  122. package/types/core/switch/index.d.ts +21 -0
  123. package/types/core/tagged-template/binding.d.ts +19 -0
  124. package/types/core/tagged-template/expression.d.ts +11 -0
  125. package/types/core/tagged-template/extract-bindings-from-schema.d.ts +7 -0
  126. package/types/core/tagged-template/index.d.ts +6 -0
  127. package/types/core/tagged-template/std.d.ts +174 -0
  128. package/types/core/tagged-template/types.d.ts +69 -0
  129. package/types/core/template/index.d.ts +97 -0
  130. package/types/core/utils/index.d.ts +47 -0
  131. package/types/fp/index.d.ts +149 -0
  132. package/types/gen/common.d.ts +6 -0
  133. package/types/index.d.ts +3 -0
  134. package/types/types.d.ts +163 -0
@@ -0,0 +1,123 @@
1
+ import type { Schema } from "@player-ui/types";
2
+
3
+ export const TaggedTemplateValueSymbol: unique symbol = Symbol(
4
+ "TaggedTemplateValue",
5
+ );
6
+
7
+ export interface TemplateRefOptions {
8
+ nestedContext?: "binding" | "expression";
9
+ }
10
+
11
+ export interface TaggedTemplateValue<T = unknown> {
12
+ [TaggedTemplateValueSymbol]: true;
13
+ /** Phantom type marker - not available at runtime */
14
+ readonly _phantomType?: T;
15
+ toValue(): string;
16
+ toRefString(options?: TemplateRefOptions): string;
17
+ toString(): string;
18
+ }
19
+
20
+ export function isTaggedTemplateValue(
21
+ value: unknown,
22
+ ): value is TaggedTemplateValue {
23
+ return (
24
+ typeof value === "object" &&
25
+ value !== null &&
26
+ TaggedTemplateValueSymbol in value
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Type that converts an object type to a bindable proxy type
32
+ * Each property access returns a TaggedTemplateValue with the correct type
33
+ */
34
+ export type BindableProxy<T> = {
35
+ readonly [K in keyof T]: T[K] extends unknown[]
36
+ ? TaggedTemplateValue<T[K]> // Arrays become bindings directly
37
+ : T[K] extends object
38
+ ? BindableProxy<T[K]> // Objects become nested proxies
39
+ : TaggedTemplateValue<T[K]>; // Primitives become bindings
40
+ };
41
+
42
+ /**
43
+ * Maps primitive Schema types to their corresponding TypeScript types
44
+ */
45
+ type PrimitiveTypeMap = {
46
+ StringType: string;
47
+ NumberType: number;
48
+ BooleanType: boolean;
49
+ };
50
+
51
+ /**
52
+ * Utility to force TypeScript to fully evaluate a type
53
+ */
54
+ type Evaluate<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
55
+
56
+ /**
57
+ * Check if a type name is primitive
58
+ */
59
+ type IsPrimitiveType<T extends string> = T extends keyof PrimitiveTypeMap
60
+ ? true
61
+ : false;
62
+
63
+ /**
64
+ * Convert a primitive type name to its TaggedTemplateValue
65
+ */
66
+ type PrimitiveToBinding<T extends string> = T extends keyof PrimitiveTypeMap
67
+ ? TaggedTemplateValue<PrimitiveTypeMap[T]>
68
+ : TaggedTemplateValue<string>;
69
+
70
+ /**
71
+ * Process a single field from a node into its binding representation
72
+ */
73
+ type ProcessField<
74
+ Field extends Schema.DataTypes,
75
+ S extends Schema.Schema,
76
+ CurrentPath extends string,
77
+ > = Field extends { type: infer TypeName extends string }
78
+ ? Field extends { isArray: true }
79
+ ? // Handle array fields
80
+ IsPrimitiveType<TypeName> extends true
81
+ ? TypeName extends "StringType"
82
+ ? { name: PrimitiveToBinding<TypeName> }
83
+ : { value: PrimitiveToBinding<TypeName> }
84
+ : TypeName extends keyof S
85
+ ? S[TypeName] extends Schema.Node
86
+ ? ProcessNodeFields<S[TypeName], S, `${CurrentPath}._current_`>
87
+ : TaggedTemplateValue<string>
88
+ : TaggedTemplateValue<string>
89
+ : // Handle non-array fields
90
+ IsPrimitiveType<TypeName> extends true
91
+ ? PrimitiveToBinding<TypeName>
92
+ : TypeName extends keyof S
93
+ ? S[TypeName] extends Schema.Node
94
+ ? ProcessNodeFields<S[TypeName], S, CurrentPath>
95
+ : TaggedTemplateValue<string>
96
+ : TaggedTemplateValue<string>
97
+ : TaggedTemplateValue<string>;
98
+
99
+ /**
100
+ * Process all fields in a node
101
+ */
102
+ type ProcessNodeFields<
103
+ Node extends Schema.Node,
104
+ S extends Schema.Schema,
105
+ BasePath extends string,
106
+ > = Evaluate<{
107
+ [K in keyof Node]: ProcessField<
108
+ Node[K],
109
+ S,
110
+ BasePath extends "" ? K & string : `${BasePath}.${K & string}`
111
+ >;
112
+ }>;
113
+
114
+ /**
115
+ * Main type to extract bindings from a schema
116
+ */
117
+ export type ExtractedBindings<S extends Schema.Schema> = S extends {
118
+ ROOT: infer RootNode;
119
+ }
120
+ ? RootNode extends Schema.Node
121
+ ? ProcessNodeFields<RootNode, S, "">
122
+ : never
123
+ : never;
@@ -0,0 +1,380 @@
1
+ import { describe, test, expect, beforeEach } from "vitest";
2
+ import type { Template, Asset } from "@player-ui/types";
3
+ import { template, isTemplate, TEMPLATE_MARKER } from "../index";
4
+ import {
5
+ type BaseBuildContext,
6
+ type SlotBranch,
7
+ resetGlobalIdSet,
8
+ } from "../../base-builder";
9
+ import { text } from "../../mocks";
10
+ import { binding as b } from "../../tagged-template";
11
+
12
+ // Mock BaseBuildContext
13
+ const mockBaseBuildContext: BaseBuildContext = {
14
+ parentId: "parent-1",
15
+ branch: {
16
+ type: "slot",
17
+ name: "test",
18
+ } as SlotBranch,
19
+ };
20
+
21
+ describe("template", () => {
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
+ })(mockBaseBuildContext);
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
+ })(mockBaseBuildContext);
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
+ })(mockBaseBuildContext);
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
+ })(mockBaseBuildContext);
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 capturedBaseBuildContext: BaseBuildContext | null = null;
120
+
121
+ const valueWithCapture = (parentCtx: BaseBuildContext): Asset<"text"> => {
122
+ capturedBaseBuildContext = 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
+ })(mockBaseBuildContext);
135
+
136
+ expect(capturedBaseBuildContext).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
+ })(mockBaseBuildContext);
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
+ })(mockBaseBuildContext);
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
+ })(mockBaseBuildContext);
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
+ })(mockBaseBuildContext);
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(mockBaseBuildContext);
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
+ });
@@ -0,0 +1,196 @@
1
+ import type { Asset, Template } from "@player-ui/types";
2
+ import {
3
+ type BaseBuildContext,
4
+ genId,
5
+ isFluentBuilder,
6
+ BranchTypes,
7
+ } from "../base-builder";
8
+ import {
9
+ isTaggedTemplateValue,
10
+ type TaggedTemplateValue,
11
+ } from "../tagged-template";
12
+
13
+ /**
14
+ * Symbol marker to identify template functions
15
+ */
16
+ export const TEMPLATE_MARKER = Symbol.for("fluent-builder-template");
17
+
18
+ /**
19
+ * Type guard to check if a function is a template function
20
+ */
21
+ export function isTemplate(fn: unknown): fn is ReturnType<typeof template> {
22
+ return (
23
+ typeof fn === "function" &&
24
+ TEMPLATE_MARKER in (fn as { [TEMPLATE_MARKER]?: unknown })
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Arguments for creating a template configuration
30
+ * @template T - The type of asset that will be created for each item in the array
31
+ */
32
+ interface TemplateArgs<T extends Asset<string>> {
33
+ /** A binding that points to an array in the model */
34
+ readonly data: string | TaggedTemplateValue;
35
+ /** A property to put the mapped objects. If not provided, will be inferred from context */
36
+ readonly output?: string;
37
+ /** The asset creator - can be a static asset, a FluentBuilder, or a builder function that returns an asset */
38
+ readonly value:
39
+ | T
40
+ | { build(context?: BaseBuildContext): T }
41
+ | (<K extends BaseBuildContext>(ctx: K) => T);
42
+ /** Whether template should be recomputed when data changes */
43
+ readonly dynamic?: boolean;
44
+ }
45
+
46
+ /**
47
+ * Creates a template configuration for dynamically creating a list of assets based on array data.
48
+ * Templates provide a way to dynamically create a list of assets, or any object, based on data from the model.
49
+ * All of the templating semantics are removed by the time it reaches an asset's transform or UI layer.
50
+ *
51
+ * Within a template, the `_index_` string can be used to substitute the array-index of the item being mapped.
52
+ *
53
+ * Multiple templates:
54
+ * - Templates can be nested. Use `_index_` for the outer loop, `_index1_` for the inner loop, and so on.
55
+ * - Multiple templates can output to the same property using the same output name. Items will be appended.
56
+ * - Templates can append to existing arrays by using the same output property name.
57
+ *
58
+ * Dynamic vs Static Templates:
59
+ * - If dynamic is false (default), the template will be parsed when a view first renders and won't update as data changes.
60
+ * - If dynamic is true, template will be updated whenever data changes while a view is still showing.
61
+ *
62
+ * @param args - The template configuration arguments
63
+ * @returns A function that takes parent context and returns a Template configuration
64
+ * @see https://player-ui.github.io/next/content/assets-views/#templates
65
+ * @example
66
+ * ```ts
67
+ * // Using a static asset
68
+ * template({
69
+ * data: binding`users`,
70
+ * output: "items",
71
+ * value: text({ value: binding`users._index_.name` })
72
+ * })(parentCtx)
73
+ * ```
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * // Using a builder function
78
+ * template({
79
+ * data: binding`users`,
80
+ * output: "items",
81
+ * value: (ctx) => text({ value: binding`users._index_.name` }).withId(genId(ctx))
82
+ * })(parentCtx)
83
+ * ```
84
+ *
85
+ * @example Multiple templates with the same output
86
+ * ```ts
87
+ * [
88
+ * template({
89
+ * data: binding`names`,
90
+ * output: "values",
91
+ * value: text({ id: `name-_index_`, value: binding`names._index_` })
92
+ * })(parentCtx),
93
+ * template({
94
+ * data: binding`otherNames`,
95
+ * output: "values",
96
+ * value: text({ id: `other-name-_index_`, value: binding`otherNames._index_` })
97
+ * })(parentCtx)
98
+ * ]
99
+ * ```
100
+ *
101
+ * @example Dynamic template that updates when data changes
102
+ * ```ts
103
+ * template({
104
+ * data: binding`users`,
105
+ * output: "items",
106
+ * dynamic: true,
107
+ * value: text({ value: binding`users._index_.name` })
108
+ * })(parentCtx)
109
+ * ```
110
+ */
111
+ export const template = <T extends Asset<string>>({
112
+ data,
113
+ output,
114
+ value,
115
+ dynamic = false,
116
+ }: TemplateArgs<T>) => {
117
+ const templateFn = (parentCtx: BaseBuildContext): Template<{ asset: T }> => {
118
+ // If output is not provided, try to infer from context
119
+ const resolvedOutput = output || inferOutputFromContext(parentCtx);
120
+
121
+ if (!resolvedOutput) {
122
+ throw new Error(
123
+ "Template output must be provided or inferrable from context. " +
124
+ "When using template in asset arrays, ensure the array property can be inferred " +
125
+ "(e.g., collection().withValues([template(...)]) infers 'values' as output).",
126
+ );
127
+ }
128
+
129
+ // Create template context for the value - use the generated parent ID to include slot context
130
+ const templateValueCtx: BaseBuildContext = {
131
+ parentId: genId(parentCtx),
132
+ branch: {
133
+ type: BranchTypes.TEMPLATE,
134
+ depth: 0,
135
+ },
136
+ };
137
+
138
+ let resolvedAsset: T;
139
+ if (isFluentBuilder(value)) {
140
+ resolvedAsset = value.build(templateValueCtx) as T;
141
+ } else if (typeof value === "function") {
142
+ const builderResult = value(templateValueCtx);
143
+ if (typeof builderResult === "function") {
144
+ resolvedAsset = (builderResult as (ctx: BaseBuildContext) => T)(
145
+ templateValueCtx,
146
+ );
147
+ } else {
148
+ resolvedAsset = builderResult;
149
+ }
150
+ } else {
151
+ if (typeof value === "object" && value !== null) {
152
+ resolvedAsset = { ...value } as T;
153
+ } else {
154
+ resolvedAsset = value;
155
+ }
156
+ }
157
+
158
+ return {
159
+ data: isTaggedTemplateValue(data) ? data.toString() : data,
160
+ output: resolvedOutput,
161
+ value: {
162
+ asset: resolvedAsset,
163
+ },
164
+ ...(dynamic && { dynamic }),
165
+ };
166
+ };
167
+
168
+ // Add the marker symbol to the function
169
+ (templateFn as typeof templateFn & { [TEMPLATE_MARKER]: true })[
170
+ TEMPLATE_MARKER
171
+ ] = true;
172
+
173
+ return templateFn;
174
+ };
175
+
176
+ /**
177
+ * Helper function to infer output property name from BaseBuildContext
178
+ */
179
+ function inferOutputFromContext(
180
+ parentCtx: BaseBuildContext,
181
+ ): string | undefined {
182
+ // Check if we're in a slot context that can give us the property name
183
+ if (parentCtx.branch?.type === "slot") {
184
+ const slotName = parentCtx.branch.name;
185
+ // Extract property name from slot names like "values-0", "items-2", etc.
186
+ const match = slotName.match(/^([a-zA-Z_][a-zA-Z0-9_]*)-\d+$/);
187
+ if (match) {
188
+ return match[1];
189
+ }
190
+ // If it's just a property name without index, use it directly
191
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(slotName)) {
192
+ return slotName;
193
+ }
194
+ }
195
+ return undefined;
196
+ }