@bravostudioai/react 0.1.0 → 0.1.2

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 (48) hide show
  1. package/dist/_virtual/main.js +3 -2
  2. package/dist/cli/commands/generate.js +161 -1438
  3. package/dist/cli/commands/generate.js.map +1 -1
  4. package/dist/codegen/generator.js +473 -0
  5. package/dist/codegen/generator.js.map +1 -0
  6. package/dist/codegen/parser.js +720 -0
  7. package/dist/codegen/parser.js.map +1 -0
  8. package/dist/components/EncoreApp.js +197 -162
  9. package/dist/components/EncoreApp.js.map +1 -1
  10. package/dist/contexts/EncoreRouterContext.js +13 -0
  11. package/dist/contexts/EncoreRouterContext.js.map +1 -0
  12. package/dist/hooks/usePusherUpdates.js +4 -2
  13. package/dist/hooks/usePusherUpdates.js.map +1 -1
  14. package/dist/lib/dynamicModules.js +75 -85
  15. package/dist/lib/dynamicModules.js.map +1 -1
  16. package/dist/lib/moduleRegistry.js +20 -0
  17. package/dist/lib/moduleRegistry.js.map +1 -0
  18. package/dist/lib/packages.js +1 -3
  19. package/dist/lib/packages.js.map +1 -1
  20. package/dist/src/cli/commands/generate.d.ts.map +1 -1
  21. package/dist/src/codegen/generator.d.ts +10 -0
  22. package/dist/src/codegen/generator.d.ts.map +1 -0
  23. package/dist/src/codegen/index.d.ts +4 -0
  24. package/dist/src/codegen/index.d.ts.map +1 -0
  25. package/dist/src/codegen/parser.d.ts +37 -0
  26. package/dist/src/codegen/parser.d.ts.map +1 -0
  27. package/dist/src/codegen/types.d.ts +53 -0
  28. package/dist/src/codegen/types.d.ts.map +1 -0
  29. package/dist/src/components/EncoreApp.d.ts +5 -1
  30. package/dist/src/components/EncoreApp.d.ts.map +1 -1
  31. package/dist/src/contexts/EncoreRouterContext.d.ts +10 -0
  32. package/dist/src/contexts/EncoreRouterContext.d.ts.map +1 -0
  33. package/dist/src/hooks/useAuthRedirect.d.ts.map +1 -1
  34. package/dist/src/lib/dynamicModules.d.ts +1 -5
  35. package/dist/src/lib/dynamicModules.d.ts.map +1 -1
  36. package/dist/src/lib/moduleRegistry.d.ts +9 -0
  37. package/dist/src/lib/moduleRegistry.d.ts.map +1 -0
  38. package/dist/src/lib/packages.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/commands/generate.ts +88 -2723
  41. package/src/codegen/generator.ts +877 -0
  42. package/src/codegen/index.ts +3 -0
  43. package/src/codegen/parser.ts +1614 -0
  44. package/src/codegen/types.ts +58 -0
  45. package/src/components/EncoreApp.tsx +75 -22
  46. package/src/contexts/EncoreRouterContext.ts +28 -0
  47. package/src/hooks/useAuthRedirect.ts +56 -55
  48. package/src/lib/packages.ts +8 -15
@@ -0,0 +1,1614 @@
1
+ import {
2
+ ComponentInfo,
3
+ SliderInfo,
4
+ InputGroupInfo,
5
+ FormInfo,
6
+ SelectInputInfo,
7
+ ActionButtonInfo,
8
+ } from "./types";
9
+
10
+ export function sanitizePropName(name: string): string {
11
+ // Convert to camelCase and remove invalid characters
12
+ const cleaned = name
13
+ .replace(/[^a-zA-Z0-9\s]/g, "") // Remove special chars
14
+ .trim();
15
+
16
+ if (!cleaned) return "item";
17
+
18
+ return cleaned
19
+ .split(/\s+/)
20
+ .map((word, index) => {
21
+ if (!word) return "";
22
+ // Handle all-uppercase words (like "SHOP", "NOW") by lowercasing them first
23
+ const normalizedWord =
24
+ word === word.toUpperCase() && word.length > 1
25
+ ? word.toLowerCase()
26
+ : word;
27
+ const firstChar = normalizedWord.charAt(0);
28
+ const rest = normalizedWord.slice(1);
29
+ if (index === 0) {
30
+ return firstChar.toLowerCase() + rest;
31
+ }
32
+ return firstChar.toUpperCase() + rest;
33
+ })
34
+ .join("")
35
+ .replace(/^[0-9]/, "_$&"); // Prefix numbers with underscore
36
+ }
37
+
38
+ export function generateQualifiedPropName(
39
+ componentName: string,
40
+ parentPath: string[]
41
+ ): string {
42
+ // Start with the component's own name
43
+ const baseName = sanitizePropName(componentName);
44
+
45
+ // If no parent path, just return the base name
46
+ if (parentPath.length === 0) {
47
+ return baseName;
48
+ }
49
+
50
+ // Filter out empty parts and reverse so we build from root to leaf
51
+ const validParentParts = parentPath
52
+ .filter((part) => part && part.trim())
53
+ .reverse();
54
+
55
+ if (validParentParts.length === 0) {
56
+ return baseName;
57
+ }
58
+
59
+ // Build qualified name: parent1Parent2ComponentName
60
+ // Each parent part is sanitized individually, then combined
61
+ const sanitizedParentParts = validParentParts.map((part) =>
62
+ sanitizePropName(part)
63
+ );
64
+
65
+ // Join parent parts (all start uppercase) with base name (starts lowercase)
66
+ const parentPrefix = sanitizedParentParts
67
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
68
+ .join("");
69
+
70
+ return parentPrefix + baseName;
71
+ }
72
+
73
+ /**
74
+ * Finds the minimal distinguishing path suffix for a component when compared to others.
75
+ * Returns the shortest suffix (from root side, working towards leaf) that makes this path unique.
76
+ *
77
+ * The algorithm finds where paths first diverge from the root, then takes the minimal
78
+ * distinguishing part from that divergence point towards the leaf.
79
+ */
80
+ export function findMinimalDistinguishingPath(
81
+ thisPath: string[],
82
+ otherPaths: string[][]
83
+ ): string[] {
84
+ if (otherPaths.length === 0) {
85
+ // No other paths to compare, return empty (no qualification needed)
86
+ return [];
87
+ }
88
+
89
+ // Find the longest common prefix (from root side)
90
+ let commonPrefixLength = 0;
91
+ const maxCommonLength = Math.min(
92
+ thisPath.length,
93
+ ...otherPaths.map((p) => p.length)
94
+ );
95
+
96
+ for (let i = 0; i < maxCommonLength; i++) {
97
+ const thisPart = thisPath[i];
98
+ // Check if all other paths have the same part at this position
99
+ const allMatch = otherPaths.every((otherPath) => {
100
+ return otherPath[i] === thisPart;
101
+ });
102
+
103
+ if (allMatch) {
104
+ commonPrefixLength++;
105
+ } else {
106
+ break;
107
+ }
108
+ }
109
+
110
+ // The distinguishing part starts after the common prefix
111
+ const distinguishingSuffix = thisPath.slice(commonPrefixLength);
112
+
113
+ // Now find the minimal suffix that's unique
114
+ // Try progressively shorter suffixes (from root side) until we find one that's unique
115
+ for (
116
+ let suffixLength = 1;
117
+ suffixLength <= distinguishingSuffix.length;
118
+ suffixLength++
119
+ ) {
120
+ const thisSuffix = distinguishingSuffix.slice(0, suffixLength);
121
+
122
+ // Check if this suffix is unique when combined with the common prefix
123
+ const isUnique = otherPaths.every((otherPath) => {
124
+ // If other path is shorter than common prefix + suffix, it can't match
125
+ if (otherPath.length < commonPrefixLength + suffixLength) {
126
+ return true; // Different length means unique
127
+ }
128
+ // Check if the suffix matches at the divergence point
129
+ const otherSuffix = otherPath.slice(
130
+ commonPrefixLength,
131
+ commonPrefixLength + suffixLength
132
+ );
133
+ return !arraysEqual(thisSuffix, otherSuffix);
134
+ });
135
+
136
+ if (isUnique) {
137
+ // Found minimal distinguishing suffix
138
+ return thisSuffix;
139
+ }
140
+ }
141
+
142
+ // If we get here, paths are identical (shouldn't happen, but handle it)
143
+ return distinguishingSuffix;
144
+ }
145
+
146
+ /**
147
+ * Helper to compare two arrays for equality
148
+ */
149
+ export function arraysEqual(a: string[], b: string[]): boolean {
150
+ if (a.length !== b.length) return false;
151
+ return a.every((val, idx) => val === b[idx]);
152
+ }
153
+
154
+ export function getComponentPropType(
155
+ componentType: string,
156
+ _componentName: string
157
+ ): string {
158
+ if (componentType === "component:image") {
159
+ return "string"; // imageUrl
160
+ }
161
+ if (componentType === "component:text") {
162
+ return "string"; // text
163
+ }
164
+ if (componentType?.startsWith("component:input-")) {
165
+ // Input types generally return strings
166
+ if (componentType === "component:input-image") {
167
+ return "string"; // image URL
168
+ }
169
+ return "string"; // text inputs, email, password, etc.
170
+ }
171
+ // Default to any for unknown types
172
+ return "any";
173
+ }
174
+
175
+ export function getComponentPropName(componentType: string): string {
176
+ if (componentType === "component:image") {
177
+ return "imageUrl";
178
+ }
179
+ if (componentType === "component:text") {
180
+ return "text";
181
+ }
182
+ return "value";
183
+ }
184
+
185
+ export function findSlidersAndDataBindings(pageData: any): SliderInfo[] {
186
+ const sliders: SliderInfo[] = [];
187
+
188
+ function traverse(node: any, path: string[] = []): void {
189
+ if (!node || typeof node !== "object") return;
190
+
191
+ // Check if this is a slider container
192
+ if (node.type === "container:slider") {
193
+ const slider: SliderInfo = {
194
+ id: node.id,
195
+ name: node.name || "Slider",
196
+ arrayContainer: null,
197
+ };
198
+
199
+ // Recursively look for containers with encore:data:array tag inside the slider
200
+ let arrayContainer: any = null;
201
+
202
+ const findArrayContainer = (containerNode: any): void => {
203
+ if (arrayContainer) return; // Already found one
204
+
205
+ if (!containerNode || typeof containerNode !== "object") return;
206
+
207
+ // Check if this node is the array container
208
+ if (
209
+ Array.isArray(containerNode.tags) &&
210
+ (containerNode.tags.includes("encore:data:array") ||
211
+ containerNode.tags.includes("bravo:data:array"))
212
+ ) {
213
+ arrayContainer = containerNode;
214
+ return;
215
+ }
216
+
217
+ // Recursively search children
218
+ if (containerNode.body && Array.isArray(containerNode.body)) {
219
+ containerNode.body.forEach(findArrayContainer);
220
+ }
221
+ if (
222
+ containerNode.containers &&
223
+ Array.isArray(containerNode.containers)
224
+ ) {
225
+ containerNode.containers.forEach(findArrayContainer);
226
+ }
227
+ if (
228
+ containerNode.components &&
229
+ Array.isArray(containerNode.components)
230
+ ) {
231
+ containerNode.components.forEach(findArrayContainer);
232
+ }
233
+ };
234
+
235
+ // Start search from the slider node itself (checking its children)
236
+ if (node.containers && Array.isArray(node.containers)) {
237
+ node.containers.forEach(findArrayContainer);
238
+ }
239
+ // Also check components if they exist directly
240
+ if (
241
+ !arrayContainer &&
242
+ node.components &&
243
+ Array.isArray(node.components)
244
+ ) {
245
+ node.components.forEach(findArrayContainer);
246
+ }
247
+
248
+ if (arrayContainer) {
249
+ const container = arrayContainer;
250
+ let components: ComponentInfo[] = [];
251
+
252
+ // Find all components with encore:data tag, and also image components
253
+ const imageComponents: any[] = [];
254
+
255
+ const findDataComponents = (
256
+ comp: any,
257
+ parentPath: string[] = []
258
+ ): void => {
259
+ if (!comp || typeof comp !== "object") return;
260
+
261
+ // Track image components separately (they might not have encore:data tag)
262
+ if (comp.type === "component:image") {
263
+ imageComponents.push(comp);
264
+ }
265
+
266
+ if (
267
+ Array.isArray(comp.tags) &&
268
+ (comp.tags.includes("encore:data") ||
269
+ comp.tags.includes("bravo:data"))
270
+ ) {
271
+ const basePropName = sanitizePropName(comp.name || "item");
272
+ const propType = getComponentPropType(comp.type, comp.name);
273
+ // const propKey = getComponentPropName(comp.type);
274
+
275
+ components.push({
276
+ id: comp.id,
277
+ name: comp.name || "Unnamed",
278
+ type: comp.type,
279
+ tags: comp.tags || [],
280
+ propName: basePropName, // Will be qualified later if needed
281
+ propType,
282
+ // Store parent path for later qualification
283
+ _parentPath: [...parentPath],
284
+ } as ComponentInfo & { _parentPath: string[] });
285
+ }
286
+
287
+ // Build parent path: include this node's name if it's a container/component with a name
288
+ // Skip generic "Frame" names - they're usually not meaningful for distinction
289
+ const currentParentPath = [...parentPath];
290
+ if (
291
+ comp.name &&
292
+ (comp.type?.startsWith("container:") ||
293
+ comp.type?.startsWith("component:"))
294
+ ) {
295
+ const name = comp.name.trim();
296
+ // Filter out generic "Frame" names (case-insensitive, with or without numbers/spaces)
297
+ // But keep meaningful names like "TripSlideFrame" that contain other words
298
+ const isGenericFrame =
299
+ /^frame\s*\d*$/i.test(name) || name.toUpperCase() === "FRAME";
300
+ if (name && !isGenericFrame) {
301
+ currentParentPath.push(comp.name);
302
+ }
303
+ }
304
+
305
+ // Recursively search children
306
+ if (comp.body && Array.isArray(comp.body)) {
307
+ comp.body.forEach((child: any) =>
308
+ findDataComponents(child, currentParentPath)
309
+ );
310
+ }
311
+ if (comp.containers && Array.isArray(comp.containers)) {
312
+ comp.containers.forEach((child: any) =>
313
+ findDataComponents(child, currentParentPath)
314
+ );
315
+ }
316
+ if (comp.components && Array.isArray(comp.components)) {
317
+ comp.components.forEach((child: any) =>
318
+ findDataComponents(child, currentParentPath)
319
+ );
320
+ }
321
+ };
322
+
323
+ if (container.components && Array.isArray(container.components)) {
324
+ container.components.forEach((comp: any) =>
325
+ findDataComponents(comp, [])
326
+ );
327
+ }
328
+
329
+ // After finding all components, if we have image components but no image with encore:data,
330
+ // add the first image component
331
+ const hasImageWithData = components.some(
332
+ (c) => c.type === "component:image"
333
+ );
334
+ if (!hasImageWithData && imageComponents.length > 0) {
335
+ const imageComp = imageComponents[0];
336
+ // For image components, use "imageUrl" as the prop name
337
+ // Clean the name first, then if it contains "image", just use "imageUrl"
338
+ const rawName = (imageComp.name || "image").toLowerCase();
339
+ const basePropName = rawName.includes("image")
340
+ ? "imageUrl"
341
+ : sanitizePropName(imageComp.name || "image");
342
+ components.push({
343
+ id: imageComp.id,
344
+ name: imageComp.name || "Image",
345
+ type: imageComp.type,
346
+ tags: imageComp.tags || [],
347
+ propName: basePropName, // Will be qualified later if needed
348
+ propType: "string", // imageUrl is always string
349
+ _parentPath: [],
350
+ } as ComponentInfo & { _parentPath: string[] });
351
+ }
352
+
353
+ // Detect duplicates and qualify them with minimal distinguishing paths
354
+ // First pass: collect all base prop names and group duplicates
355
+ const propNameGroups = new Map<
356
+ string,
357
+ Array<ComponentInfo & { _parentPath: string[] }>
358
+ >();
359
+ components.forEach((comp) => {
360
+ const compWithPath = comp as ComponentInfo & {
361
+ _parentPath: string[];
362
+ };
363
+ const baseName = comp.propName;
364
+ if (!propNameGroups.has(baseName)) {
365
+ propNameGroups.set(baseName, []);
366
+ }
367
+ propNameGroups.get(baseName)!.push(compWithPath);
368
+ });
369
+
370
+ // Second pass: for each group with duplicates, find minimal distinguishing paths
371
+ // and ensure all qualified names are unique
372
+ propNameGroups.forEach((group, _baseName) => {
373
+ if (group.length === 1) {
374
+ // No duplicates, keep the simple name
375
+ return;
376
+ }
377
+
378
+ // First, find minimal distinguishing paths for all components
379
+ group.forEach((comp) => {
380
+ const otherPaths = group
381
+ .filter((c) => c.id !== comp.id)
382
+ .map((c) => c._parentPath || []);
383
+
384
+ const minimalPath = findMinimalDistinguishingPath(
385
+ comp._parentPath || [],
386
+ otherPaths
387
+ );
388
+
389
+ // Use the minimal distinguishing path to qualify the name
390
+ comp.propName = generateQualifiedPropName(
391
+ comp.name || "item",
392
+ minimalPath
393
+ );
394
+ });
395
+
396
+ // Check if qualified names are still duplicates and expand paths if needed
397
+ let hasDuplicates = true;
398
+ let iteration = 0;
399
+ const maxIterations = 10; // Safety limit
400
+
401
+ while (hasDuplicates && iteration < maxIterations) {
402
+ iteration++;
403
+ const qualifiedNameGroups = new Map<
404
+ string,
405
+ Array<ComponentInfo & { _parentPath: string[] }>
406
+ >();
407
+ group.forEach((comp) => {
408
+ if (!qualifiedNameGroups.has(comp.propName)) {
409
+ qualifiedNameGroups.set(comp.propName, []);
410
+ }
411
+ qualifiedNameGroups.get(comp.propName)!.push(comp);
412
+ });
413
+
414
+ hasDuplicates = false;
415
+ // For each group of still-duplicated qualified names, expand their paths
416
+ qualifiedNameGroups.forEach((dupGroup, _qualifiedName) => {
417
+ if (dupGroup.length > 1) {
418
+ hasDuplicates = true;
419
+ // Expand the distinguishing path for each duplicate
420
+ dupGroup.forEach((comp) => {
421
+ // Find a longer distinguishing path by comparing with others in the duplicate group
422
+ const fullPath = comp._parentPath || [];
423
+ const otherFullPaths = dupGroup
424
+ .filter((c) => c.id !== comp.id)
425
+ .map((c) => c._parentPath || []);
426
+
427
+ // Find where this path diverges from others in the duplicate group
428
+ let commonPrefixLength = 0;
429
+ const maxCommonLength = Math.min(
430
+ fullPath.length,
431
+ ...otherFullPaths.map((p) => p.length)
432
+ );
433
+
434
+ for (let i = 0; i < maxCommonLength; i++) {
435
+ const thisPart = fullPath[i];
436
+ const allMatch = otherFullPaths.every((otherPath) => {
437
+ return otherPath[i] === thisPart;
438
+ });
439
+ if (allMatch) {
440
+ commonPrefixLength++;
441
+ } else {
442
+ break;
443
+ }
444
+ }
445
+
446
+ // Use progressively more of the distinguishing suffix until unique
447
+ const distinguishingSuffix =
448
+ fullPath.slice(commonPrefixLength);
449
+
450
+ // Try expanding the distinguishing suffix until we find a unique name
451
+ let foundUnique = false;
452
+ for (
453
+ let suffixLength = 1;
454
+ suffixLength <= distinguishingSuffix.length;
455
+ suffixLength++
456
+ ) {
457
+ const expandedPath = distinguishingSuffix.slice(
458
+ 0,
459
+ suffixLength
460
+ );
461
+ const testQualifiedName = generateQualifiedPropName(
462
+ comp.name || "item",
463
+ expandedPath
464
+ );
465
+
466
+ // Check if this qualified name is unique among ALL components (not just duplicates)
467
+ const isUnique = components.every((otherComp) => {
468
+ if (otherComp.id === comp.id) return true;
469
+ // If other component is in the same duplicate group, compare expanded paths
470
+ if (dupGroup.some((c) => c.id === otherComp.id)) {
471
+ const otherFullPath =
472
+ (
473
+ otherComp as ComponentInfo & {
474
+ _parentPath: string[];
475
+ }
476
+ )._parentPath || [];
477
+ const otherCommonPrefixLength = Math.min(
478
+ commonPrefixLength,
479
+ otherFullPath.length
480
+ );
481
+ const otherDistinguishingSuffix = otherFullPath.slice(
482
+ otherCommonPrefixLength
483
+ );
484
+ const otherExpandedPath =
485
+ otherDistinguishingSuffix.slice(0, suffixLength);
486
+ const otherQualifiedName = generateQualifiedPropName(
487
+ otherComp.name || "item",
488
+ otherExpandedPath
489
+ );
490
+ return testQualifiedName !== otherQualifiedName;
491
+ }
492
+ // For components outside the duplicate group, just check the final prop name
493
+ return testQualifiedName !== otherComp.propName;
494
+ });
495
+
496
+ if (isUnique) {
497
+ comp.propName = testQualifiedName;
498
+ foundUnique = true;
499
+ break;
500
+ }
501
+ }
502
+
503
+ // If we couldn't find a unique name with the distinguishing suffix,
504
+ // use the distinguishing suffix we found (it's the minimal we can do)
505
+ // We'll handle truly identical paths with numeric suffixes in the final pass
506
+ if (!foundUnique) {
507
+ // Use the distinguishing suffix - it's the minimal distinguishing part
508
+ // Even if it's not globally unique yet, it's better than the full path
509
+ comp.propName = generateQualifiedPropName(
510
+ comp.name || "item",
511
+ distinguishingSuffix.length > 0
512
+ ? distinguishingSuffix
513
+ : []
514
+ );
515
+ }
516
+ });
517
+ }
518
+ });
519
+ }
520
+
521
+ // Final check: if there are still duplicates after using full paths,
522
+ // and they have identical paths, use numeric suffixes as last resort
523
+ const finalQualifiedNameGroups = new Map<
524
+ string,
525
+ Array<ComponentInfo & { _parentPath: string[] }>
526
+ >();
527
+ group.forEach((comp) => {
528
+ if (!finalQualifiedNameGroups.has(comp.propName)) {
529
+ finalQualifiedNameGroups.set(comp.propName, []);
530
+ }
531
+ finalQualifiedNameGroups.get(comp.propName)!.push(comp);
532
+ });
533
+
534
+ finalQualifiedNameGroups.forEach(
535
+ (finalDupGroup, finalQualifiedName) => {
536
+ if (finalDupGroup.length > 1) {
537
+ // Check if all duplicates have identical paths
538
+ const allPathsIdentical = finalDupGroup.every((comp) => {
539
+ const thisPath = comp._parentPath || [];
540
+ return finalDupGroup.every((otherComp) => {
541
+ if (otherComp.id === comp.id) return true;
542
+ const otherPath = otherComp._parentPath || [];
543
+ return arraysEqual(thisPath, otherPath);
544
+ });
545
+ });
546
+
547
+ // Only use numeric suffixes if paths are truly identical
548
+ if (allPathsIdentical) {
549
+ let index = 0;
550
+ finalDupGroup.forEach((comp) => {
551
+ if (index > 0) {
552
+ comp.propName = `${finalQualifiedName}${index + 1}`;
553
+ }
554
+ index++;
555
+ });
556
+ }
557
+ }
558
+ }
559
+ );
560
+
561
+ // Remove the temporary _parentPath property
562
+ group.forEach((comp) => {
563
+ delete (comp as any)._parentPath;
564
+ });
565
+ });
566
+
567
+ // If we have an image component, remove color components with similar names
568
+ // (they're usually placeholders/backgrounds)
569
+ if (imageComponents.length > 0) {
570
+ const imageComp = imageComponents[0];
571
+ const imageName = (imageComp.name || "").toLowerCase();
572
+ components = components.filter((comp) => {
573
+ // Keep image components
574
+ if (comp.type === "component:image") return true;
575
+ // Remove color components that seem to be placeholders for images
576
+ if (comp.type === "component:color") {
577
+ const compName = (comp.name || "").toLowerCase();
578
+ // If color component name is similar to image name, it's likely a placeholder
579
+ if (imageName.includes(compName) || compName.includes("image")) {
580
+ return false;
581
+ }
582
+ }
583
+ return true;
584
+ });
585
+ }
586
+
587
+ slider.arrayContainer = {
588
+ id: container.id,
589
+ name: container.name || "Item",
590
+ propName: sanitizePropName(container.name || "items"),
591
+ components,
592
+ };
593
+ }
594
+
595
+ sliders.push(slider);
596
+ }
597
+
598
+ // Recursively traverse children
599
+ if (node.body && Array.isArray(node.body)) {
600
+ node.body.forEach((child: any) => traverse(child, [...path, "body"]));
601
+ }
602
+ if (node.containers && Array.isArray(node.containers)) {
603
+ node.containers.forEach((child: any) =>
604
+ traverse(child, [...path, "containers"])
605
+ );
606
+ }
607
+ if (node.components && Array.isArray(node.components)) {
608
+ node.components.forEach((child: any) =>
609
+ traverse(child, [...path, "components"])
610
+ );
611
+ }
612
+ }
613
+
614
+ // Start traversal from page data
615
+ // Try multiple possible locations for the body
616
+ const body =
617
+ pageData.data?.body || pageData.body || (pageData as any).data?.body || [];
618
+
619
+ if (Array.isArray(body) && body.length > 0) {
620
+ body.forEach((node: any) => traverse(node));
621
+ }
622
+
623
+ return sliders;
624
+ }
625
+
626
+ export function findStandaloneComponents(pageData: any): ComponentInfo[] {
627
+ const components: ComponentInfo[] = [];
628
+ const sliderIds = new Set<string>();
629
+
630
+ // First, collect all slider IDs to exclude their children
631
+ function collectSliderIds(node: any): void {
632
+ if (!node || typeof node !== "object") return;
633
+
634
+ if (node.type === "container:slider") {
635
+ sliderIds.add(node.id);
636
+ }
637
+
638
+ if (node.body && Array.isArray(node.body)) {
639
+ node.body.forEach(collectSliderIds);
640
+ }
641
+ if (node.containers && Array.isArray(node.containers)) {
642
+ node.containers.forEach(collectSliderIds);
643
+ }
644
+ }
645
+
646
+ // Traverse to find standalone components with bravo:data tags
647
+ // Track parent component names for qualification
648
+ function traverse(
649
+ node: any,
650
+ parentId?: string,
651
+ parentPath: string[] = []
652
+ ): void {
653
+ if (!node || typeof node !== "object") return;
654
+
655
+ // Skip if we're inside a slider
656
+ if (parentId && sliderIds.has(parentId)) return;
657
+
658
+ // Check if this component has bravo:data tag
659
+ if (
660
+ Array.isArray(node.tags) &&
661
+ (node.tags.includes("encore:data") || node.tags.includes("bravo:data")) &&
662
+ (node.type === "component:text" || node.type === "component:image")
663
+ ) {
664
+ const basePropName = sanitizePropName(node.name || "item");
665
+ const propType = getComponentPropType(node.type, node.name);
666
+
667
+ components.push({
668
+ id: node.id,
669
+ name: node.name || "Unnamed",
670
+ type: node.type,
671
+ tags: node.tags || [],
672
+ propName: basePropName, // Will be qualified later if needed
673
+ propType,
674
+ // Store parent path for later qualification
675
+ _parentPath: [...parentPath],
676
+ } as ComponentInfo & { _parentPath: string[] });
677
+ }
678
+
679
+ // Build parent path: include this node's name if it's a container/component with a name
680
+ // Skip generic "Frame" names - they're usually not meaningful for distinction
681
+ const currentParentPath = [...parentPath];
682
+ if (
683
+ node.name &&
684
+ (node.type?.startsWith("container:") ||
685
+ node.type?.startsWith("component:"))
686
+ ) {
687
+ const name = node.name.trim();
688
+ // Filter out generic "Frame" names (case-insensitive, with or without numbers/spaces)
689
+ // But keep meaningful names like "TripSlideFrame" that contain other words
690
+ const isGenericFrame =
691
+ /^frame\s*\d*$/i.test(name) || name.toUpperCase() === "FRAME";
692
+ if (name && !isGenericFrame) {
693
+ currentParentPath.push(node.name);
694
+ }
695
+ }
696
+
697
+ // Recursively traverse children
698
+ const currentId = node.id;
699
+ if (node.body && Array.isArray(node.body)) {
700
+ node.body.forEach((child: any) =>
701
+ traverse(child, currentId, currentParentPath)
702
+ );
703
+ }
704
+ if (node.containers && Array.isArray(node.containers)) {
705
+ node.containers.forEach((child: any) =>
706
+ traverse(child, currentId, currentParentPath)
707
+ );
708
+ }
709
+ if (node.components && Array.isArray(node.components)) {
710
+ node.components.forEach((child: any) =>
711
+ traverse(child, currentId, currentParentPath)
712
+ );
713
+ }
714
+ }
715
+
716
+ // Start traversal from page data
717
+ const body =
718
+ pageData.data?.body || pageData.body || (pageData as any).data?.body || [];
719
+
720
+ if (Array.isArray(body) && body.length > 0) {
721
+ body.forEach(collectSliderIds);
722
+ body.forEach((node: any) => traverse(node));
723
+ }
724
+
725
+ // Detect duplicates and qualify them with minimal distinguishing paths
726
+ // First pass: collect all base prop names and group duplicates
727
+ const propNameGroups = new Map<
728
+ string,
729
+ Array<ComponentInfo & { _parentPath: string[] }>
730
+ >();
731
+ components.forEach((comp) => {
732
+ const compWithPath = comp as ComponentInfo & { _parentPath: string[] };
733
+ const baseName = comp.propName;
734
+ if (!propNameGroups.has(baseName)) {
735
+ propNameGroups.set(baseName, []);
736
+ }
737
+ propNameGroups.get(baseName)!.push(compWithPath);
738
+ });
739
+
740
+ // Second pass: for each group with duplicates, find minimal distinguishing paths
741
+ // and ensure all qualified names are unique
742
+ propNameGroups.forEach((group, _baseName) => {
743
+ if (group.length === 1) {
744
+ // No duplicates, keep the simple name
745
+ return;
746
+ }
747
+
748
+ // First, find minimal distinguishing paths for all components
749
+ group.forEach((comp) => {
750
+ const otherPaths = group
751
+ .filter((c) => c.id !== comp.id)
752
+ .map((c) => c._parentPath || []);
753
+
754
+ const minimalPath = findMinimalDistinguishingPath(
755
+ comp._parentPath || [],
756
+ otherPaths
757
+ );
758
+
759
+ // Use the minimal distinguishing path to qualify the name
760
+ comp.propName = generateQualifiedPropName(
761
+ comp.name || "item",
762
+ minimalPath
763
+ );
764
+ });
765
+
766
+ // Check if qualified names are still duplicates and expand paths if needed
767
+ let hasDuplicates = true;
768
+ let iteration = 0;
769
+ const maxIterations = 10; // Safety limit
770
+
771
+ while (hasDuplicates && iteration < maxIterations) {
772
+ iteration++;
773
+ const qualifiedNameGroups = new Map<
774
+ string,
775
+ Array<ComponentInfo & { _parentPath: string[] }>
776
+ >();
777
+ group.forEach((comp) => {
778
+ if (!qualifiedNameGroups.has(comp.propName)) {
779
+ qualifiedNameGroups.set(comp.propName, []);
780
+ }
781
+ qualifiedNameGroups.get(comp.propName)!.push(comp);
782
+ });
783
+
784
+ hasDuplicates = false;
785
+ // For each group of still-duplicated qualified names, expand their paths
786
+ qualifiedNameGroups.forEach((dupGroup, _qualifiedName) => {
787
+ if (dupGroup.length > 1) {
788
+ hasDuplicates = true;
789
+ // Expand the distinguishing path for each duplicate
790
+ dupGroup.forEach((comp) => {
791
+ // Find a longer distinguishing path by comparing with others in the duplicate group
792
+ const fullPath = comp._parentPath || [];
793
+ const otherFullPaths = dupGroup
794
+ .filter((c) => c.id !== comp.id)
795
+ .map((c) => c._parentPath || []);
796
+
797
+ // Find where this path diverges from others in the duplicate group
798
+ let commonPrefixLength = 0;
799
+ const maxCommonLength = Math.min(
800
+ fullPath.length,
801
+ ...otherFullPaths.map((p) => p.length)
802
+ );
803
+
804
+ for (let i = 0; i < maxCommonLength; i++) {
805
+ const thisPart = fullPath[i];
806
+ const allMatch = otherFullPaths.every((otherPath) => {
807
+ return otherPath[i] === thisPart;
808
+ });
809
+ if (allMatch) {
810
+ commonPrefixLength++;
811
+ } else {
812
+ break;
813
+ }
814
+ }
815
+
816
+ // Use progressively more of the distinguishing suffix until unique
817
+ const distinguishingSuffix = fullPath.slice(commonPrefixLength);
818
+
819
+ // Try expanding the distinguishing suffix until we find a unique name
820
+ let foundUnique = false;
821
+ for (
822
+ let suffixLength = 1;
823
+ suffixLength <= distinguishingSuffix.length;
824
+ suffixLength++
825
+ ) {
826
+ const expandedPath = distinguishingSuffix.slice(0, suffixLength);
827
+ const testQualifiedName = generateQualifiedPropName(
828
+ comp.name || "item",
829
+ expandedPath
830
+ );
831
+
832
+ // Check if this qualified name is unique among ALL components (not just duplicates)
833
+ const isUnique = components.every((otherComp) => {
834
+ if (otherComp.id === comp.id) return true;
835
+ // If other component is in the same duplicate group, compare expanded paths
836
+ if (dupGroup.some((c) => c.id === otherComp.id)) {
837
+ const otherFullPath =
838
+ (otherComp as ComponentInfo & { _parentPath: string[] })
839
+ ._parentPath || [];
840
+ const otherCommonPrefixLength = Math.min(
841
+ commonPrefixLength,
842
+ otherFullPath.length
843
+ );
844
+ const otherDistinguishingSuffix = otherFullPath.slice(
845
+ otherCommonPrefixLength
846
+ );
847
+ const otherExpandedPath = otherDistinguishingSuffix.slice(
848
+ 0,
849
+ suffixLength
850
+ );
851
+ const otherQualifiedName = generateQualifiedPropName(
852
+ otherComp.name || "item",
853
+ otherExpandedPath
854
+ );
855
+ return testQualifiedName !== otherQualifiedName;
856
+ }
857
+ // For components outside the duplicate group, just check the final prop name
858
+ return testQualifiedName !== otherComp.propName;
859
+ });
860
+
861
+ if (isUnique) {
862
+ comp.propName = testQualifiedName;
863
+ foundUnique = true;
864
+ break;
865
+ }
866
+ }
867
+
868
+ // If we couldn't find a unique name with the distinguishing suffix,
869
+ // use the full path to ensure uniqueness (even if it makes names longer)
870
+ if (!foundUnique) {
871
+ comp.propName = generateQualifiedPropName(
872
+ comp.name || "item",
873
+ fullPath
874
+ );
875
+ }
876
+ });
877
+ }
878
+ });
879
+ }
880
+
881
+ // Final check: if there are still duplicates after using full paths,
882
+ // and they have identical paths, use numeric suffixes as last resort
883
+ const finalQualifiedNameGroups = new Map<
884
+ string,
885
+ Array<ComponentInfo & { _parentPath: string[] }>
886
+ >();
887
+ group.forEach((comp) => {
888
+ if (!finalQualifiedNameGroups.has(comp.propName)) {
889
+ finalQualifiedNameGroups.set(comp.propName, []);
890
+ }
891
+ finalQualifiedNameGroups.get(comp.propName)!.push(comp);
892
+ });
893
+
894
+ finalQualifiedNameGroups.forEach((finalDupGroup, finalQualifiedName) => {
895
+ if (finalDupGroup.length > 1) {
896
+ // Check if all duplicates have identical paths
897
+ const allPathsIdentical = finalDupGroup.every((comp) => {
898
+ const thisPath = comp._parentPath || [];
899
+ return finalDupGroup.every((otherComp) => {
900
+ if (otherComp.id === comp.id) return true;
901
+ const otherPath = otherComp._parentPath || [];
902
+ return arraysEqual(thisPath, otherPath);
903
+ });
904
+ });
905
+
906
+ // Only use numeric suffixes if paths are truly identical
907
+ if (allPathsIdentical) {
908
+ let index = 0;
909
+ finalDupGroup.forEach((comp) => {
910
+ if (index > 0) {
911
+ comp.propName = `${finalQualifiedName}${index + 1}`;
912
+ }
913
+ index++;
914
+ });
915
+ }
916
+ }
917
+ });
918
+
919
+ // Remove the temporary _parentPath property
920
+ group.forEach((comp) => {
921
+ delete (comp as any)._parentPath;
922
+ });
923
+ });
924
+
925
+ return components;
926
+ }
927
+
928
+ export function findInputGroups(pageData: any): InputGroupInfo[] {
929
+ const groupsMap = new Map<string, InputGroupInfo>();
930
+
931
+ function traverse(node: any): void {
932
+ if (!node || typeof node !== "object") return;
933
+
934
+ // Check if this is an input-stateful-set with input-group tag
935
+ if (
936
+ node.type === "component:input-stateful-set" &&
937
+ Array.isArray(node.tags)
938
+ ) {
939
+ const inputGroupTag = node.tags.find((tag: string) =>
940
+ tag.startsWith("input-group:")
941
+ );
942
+ if (inputGroupTag) {
943
+ const parts = inputGroupTag.split(":");
944
+ if (parts.length >= 3) {
945
+ const groupType = parts[1];
946
+ const groupName = parts[2];
947
+
948
+ if (!groupsMap.has(groupName)) {
949
+ groupsMap.set(groupName, {
950
+ groupName,
951
+ groupType,
952
+ elements: [],
953
+ });
954
+ }
955
+
956
+ const group = groupsMap.get(groupName)!;
957
+ group.elements.push({
958
+ id: node.id,
959
+ name: node.name || "Unnamed",
960
+ });
961
+ }
962
+ }
963
+ }
964
+
965
+ // Recursively traverse children
966
+ if (node.body && Array.isArray(node.body)) {
967
+ node.body.forEach(traverse);
968
+ }
969
+ if (node.containers && Array.isArray(node.containers)) {
970
+ node.containers.forEach(traverse);
971
+ }
972
+ if (node.components && Array.isArray(node.components)) {
973
+ node.components.forEach(traverse);
974
+ }
975
+ }
976
+
977
+ // Start traversal from page data
978
+ const body =
979
+ pageData.data?.body || pageData.body || (pageData as any).data?.body || [];
980
+
981
+ if (Array.isArray(body) && body.length > 0) {
982
+ body.forEach((node: any) => traverse(node));
983
+ }
984
+
985
+ return Array.from(groupsMap.values());
986
+ }
987
+
988
+ export function findForms(pageData: any): FormInfo[] {
989
+ const forms: FormInfo[] = [];
990
+
991
+ function traverse(node: any, parentContainer?: any): void {
992
+ if (!node || typeof node !== "object") return;
993
+
994
+ // Check if this is a container that might be a form
995
+ const isContainer =
996
+ node.type?.startsWith("container:") || node.type === "container:default";
997
+ const isNamedForm =
998
+ node.name?.toLowerCase().includes("form") ||
999
+ (Array.isArray(node.tags) && node.tags.includes("form"));
1000
+
1001
+ // Check if this container or any child has a submit action
1002
+ let hasSubmitAction = false;
1003
+ let submitButtonId: string | undefined;
1004
+
1005
+ function checkForSubmitAction(n: any): void {
1006
+ if (!n || typeof n !== "object") return;
1007
+
1008
+ // Check if this node has a submit action
1009
+ if (
1010
+ Array.isArray(n.tags) &&
1011
+ (n.tags.includes("action:submit") || n.tags.includes("submit"))
1012
+ ) {
1013
+ hasSubmitAction = true;
1014
+ submitButtonId = n.id;
1015
+ return;
1016
+ }
1017
+
1018
+ // Check actions
1019
+ if (n.actions?.tap?.action === "submit") {
1020
+ hasSubmitAction = true;
1021
+ submitButtonId = n.id;
1022
+ return;
1023
+ }
1024
+
1025
+ // Recursively check children
1026
+ if (n.components && Array.isArray(n.components)) {
1027
+ n.components.forEach(checkForSubmitAction);
1028
+ }
1029
+ if (n.body && Array.isArray(n.body)) {
1030
+ n.body.forEach(checkForSubmitAction);
1031
+ }
1032
+ }
1033
+
1034
+ // If this looks like a form container, check for submit actions
1035
+ if (isContainer && (isNamedForm || parentContainer === undefined)) {
1036
+ checkForSubmitAction(node);
1037
+ }
1038
+
1039
+ // If we found a form container (has submit action or is named "form")
1040
+ if (isContainer && (hasSubmitAction || isNamedForm)) {
1041
+ const inputs: FormInfo["inputs"] = [];
1042
+
1043
+ // Find all input components within this container
1044
+ const findInputs = (n: any, parentPath: string[] = []): void => {
1045
+ if (!n || typeof n !== "object") return;
1046
+
1047
+ // Check if this is an input component
1048
+ if (
1049
+ n.type?.startsWith("component:input-") ||
1050
+ n.type === "component:input-text" ||
1051
+ n.type === "component:input-image" ||
1052
+ n.type === "component:input-email" ||
1053
+ n.type === "component:input-password" ||
1054
+ n.type === "component:input-select"
1055
+ ) {
1056
+ const basePropName = sanitizePropName(n.name || "Unnamed input");
1057
+ inputs.push({
1058
+ id: n.id,
1059
+ name: n.name || "Unnamed input",
1060
+ type: n.type,
1061
+ propName: basePropName, // Will be qualified later if needed
1062
+ _parentPath: [...parentPath], // Store parent path for qualification
1063
+ });
1064
+ }
1065
+
1066
+ // Build parent path: include this node's name if it's a container/component with a name
1067
+ // Skip generic "Frame" names - they're usually not meaningful for distinction
1068
+ const currentParentPath = [...parentPath];
1069
+ if (
1070
+ n.name &&
1071
+ (n.type?.startsWith("container:") || n.type?.startsWith("component:"))
1072
+ ) {
1073
+ const name = n.name.trim();
1074
+ // Filter out generic "Frame" names (case-insensitive, with or without numbers/spaces)
1075
+ // But keep meaningful names like "TripSlideFrame" that contain other words
1076
+ const isGenericFrame =
1077
+ /^frame\s*\d*$/i.test(name) || name.toUpperCase() === "FRAME";
1078
+ if (name && !isGenericFrame) {
1079
+ currentParentPath.push(n.name);
1080
+ }
1081
+ }
1082
+
1083
+ // Recursively search children
1084
+ if (n.components && Array.isArray(n.components)) {
1085
+ n.components.forEach((child: any) =>
1086
+ findInputs(child, currentParentPath)
1087
+ );
1088
+ }
1089
+ if (n.body && Array.isArray(n.body)) {
1090
+ n.body.forEach((child: any) => findInputs(child, currentParentPath));
1091
+ }
1092
+ };
1093
+
1094
+ findInputs(node, []);
1095
+
1096
+ // Only add form if it has inputs
1097
+ if (inputs.length > 0) {
1098
+ forms.push({
1099
+ formId: node.id,
1100
+ formName: node.name || "Form",
1101
+ submitButtonId,
1102
+ inputs,
1103
+ });
1104
+ }
1105
+ }
1106
+
1107
+ // Recursively traverse children
1108
+ if (node.body && Array.isArray(node.body)) {
1109
+ node.body.forEach((child: any) =>
1110
+ traverse(child, isContainer ? node : parentContainer)
1111
+ );
1112
+ }
1113
+ if (node.containers && Array.isArray(node.containers)) {
1114
+ node.containers.forEach((child: any) =>
1115
+ traverse(child, isContainer ? node : parentContainer)
1116
+ );
1117
+ }
1118
+ if (node.components && Array.isArray(node.components)) {
1119
+ node.components.forEach((child: any) =>
1120
+ traverse(child, isContainer ? node : parentContainer)
1121
+ );
1122
+ }
1123
+ }
1124
+
1125
+ // Start traversal from page data
1126
+ const body =
1127
+ pageData.data?.body || pageData.body || (pageData as any).data?.body || [];
1128
+
1129
+ if (Array.isArray(body) && body.length > 0) {
1130
+ body.forEach((node: any) => traverse(node));
1131
+ }
1132
+
1133
+ return forms;
1134
+ }
1135
+
1136
+ /**
1137
+ * Finds standalone select input components (input-select) that are NOT inside forms.
1138
+ * These should be exposed as controlled inputs with value and onChange props.
1139
+ */
1140
+ export function findStandaloneSelectInputs(
1141
+ pageData: any,
1142
+ forms: FormInfo[]
1143
+ ): SelectInputInfo[] {
1144
+ const selectInputs: SelectInputInfo[] = [];
1145
+
1146
+ // Collect all input IDs that are already in forms
1147
+ const formInputIds = new Set<string>();
1148
+ forms.forEach((form) => {
1149
+ form.inputs.forEach((input) => {
1150
+ formInputIds.add(input.id);
1151
+ });
1152
+ });
1153
+
1154
+ // Traverse to find input-select components not in forms
1155
+ function traverse(node: any, parentPath: string[] = []): void {
1156
+ if (!node || typeof node !== "object") return;
1157
+
1158
+ // Check if this is an input-select component
1159
+ if (node.type === "component:input-select" && !formInputIds.has(node.id)) {
1160
+ const basePropName = sanitizePropName(node.name || "selectInput");
1161
+ selectInputs.push({
1162
+ id: node.id,
1163
+ name: node.name || "Select Input",
1164
+ propName: basePropName,
1165
+ _parentPath: [...parentPath],
1166
+ });
1167
+ }
1168
+
1169
+ // Build parent path for qualification
1170
+ const currentParentPath = [...parentPath];
1171
+ if (
1172
+ node.name &&
1173
+ (node.type?.startsWith("container:") ||
1174
+ node.type?.startsWith("component:"))
1175
+ ) {
1176
+ const name = node.name.trim();
1177
+ const isGenericFrame =
1178
+ /^frame\s*\d*$/i.test(name) || name.toUpperCase() === "FRAME";
1179
+ if (name && !isGenericFrame) {
1180
+ currentParentPath.push(node.name);
1181
+ }
1182
+ }
1183
+
1184
+ // Recursively traverse children
1185
+ if (node.body && Array.isArray(node.body)) {
1186
+ node.body.forEach((child: any) => traverse(child, currentParentPath));
1187
+ }
1188
+ if (node.containers && Array.isArray(node.containers)) {
1189
+ node.containers.forEach((child: any) =>
1190
+ traverse(child, currentParentPath)
1191
+ );
1192
+ }
1193
+ if (node.components && Array.isArray(node.components)) {
1194
+ node.components.forEach((child: any) =>
1195
+ traverse(child, currentParentPath)
1196
+ );
1197
+ }
1198
+ }
1199
+
1200
+ // Start traversal from page data
1201
+ const body =
1202
+ pageData.data?.body || pageData.body || (pageData as any).data?.body || [];
1203
+
1204
+ if (Array.isArray(body) && body.length > 0) {
1205
+ body.forEach((node: any) => traverse(node));
1206
+ }
1207
+
1208
+ // Qualify duplicate prop names
1209
+ qualifySelectInputs(selectInputs);
1210
+
1211
+ return selectInputs;
1212
+ }
1213
+
1214
+ /**
1215
+ * Qualifies select input prop names to ensure uniqueness.
1216
+ */
1217
+ function qualifySelectInputs(selectInputs: SelectInputInfo[]): void {
1218
+ const propNameGroups = new Map<
1219
+ string,
1220
+ Array<SelectInputInfo & { _parentPath: string[] }>
1221
+ >();
1222
+
1223
+ selectInputs.forEach((input) => {
1224
+ const inputWithPath = input as SelectInputInfo & { _parentPath: string[] };
1225
+ const baseName = input.propName;
1226
+ if (!propNameGroups.has(baseName)) {
1227
+ propNameGroups.set(baseName, []);
1228
+ }
1229
+ propNameGroups.get(baseName)!.push(inputWithPath);
1230
+ });
1231
+
1232
+ propNameGroups.forEach((group, _baseName) => {
1233
+ if (group.length === 1) {
1234
+ delete (group[0] as any)._parentPath;
1235
+ return;
1236
+ }
1237
+
1238
+ // Find minimal distinguishing paths for duplicates
1239
+ group.forEach((input) => {
1240
+ const otherPaths = group
1241
+ .filter((i) => i.id !== input.id)
1242
+ .map((i) => i._parentPath || []);
1243
+
1244
+ const minimalPath = findMinimalDistinguishingPath(
1245
+ input._parentPath || [],
1246
+ otherPaths
1247
+ );
1248
+
1249
+ input.propName = generateQualifiedPropName(
1250
+ input.name || "input",
1251
+ minimalPath
1252
+ );
1253
+ });
1254
+
1255
+ // Clean up
1256
+ group.forEach((input) => {
1257
+ delete (input as any)._parentPath;
1258
+ });
1259
+ });
1260
+ }
1261
+
1262
+ /**
1263
+ * Finds action buttons - components that have action tags (action:remote, action:link, etc.)
1264
+ * or have actions defined. These should be exposed with onClick handlers.
1265
+ */
1266
+ export function findActionButtons(pageData: any): ActionButtonInfo[] {
1267
+ const buttons: ActionButtonInfo[] = [];
1268
+
1269
+ function traverse(node: any, parentPath: string[] = []): void {
1270
+ if (!node || typeof node !== "object") return;
1271
+
1272
+ // Check if this component has an action tag or actions defined
1273
+ const hasActionTag =
1274
+ Array.isArray(node.tags) &&
1275
+ node.tags.some((tag: string) => tag.startsWith("action:"));
1276
+ const hasActions = node.actions && typeof node.actions === "object";
1277
+
1278
+ if (hasActionTag || hasActions) {
1279
+ // Determine the action type
1280
+ let actionType = "tap";
1281
+ if (Array.isArray(node.tags)) {
1282
+ const actionTag = node.tags.find((tag: string) =>
1283
+ tag.startsWith("action:")
1284
+ );
1285
+ if (actionTag) {
1286
+ actionType = actionTag.replace("action:", "");
1287
+ }
1288
+ }
1289
+ if (node.actions?.tap?.action) {
1290
+ actionType = node.actions.tap.action;
1291
+ }
1292
+
1293
+ const basePropName = sanitizePropName(node.name || "button");
1294
+ buttons.push({
1295
+ id: node.id,
1296
+ name: node.name || "Button",
1297
+ propName: basePropName,
1298
+ actionType,
1299
+ _parentPath: [...parentPath],
1300
+ });
1301
+ }
1302
+
1303
+ // Build parent path for qualification
1304
+ const currentParentPath = [...parentPath];
1305
+ if (
1306
+ node.name &&
1307
+ (node.type?.startsWith("container:") ||
1308
+ node.type?.startsWith("component:"))
1309
+ ) {
1310
+ const name = node.name.trim();
1311
+ const isGenericFrame =
1312
+ /^frame\s*\d*$/i.test(name) || name.toUpperCase() === "FRAME";
1313
+ if (name && !isGenericFrame) {
1314
+ currentParentPath.push(node.name);
1315
+ }
1316
+ }
1317
+
1318
+ // Recursively traverse children
1319
+ if (node.body && Array.isArray(node.body)) {
1320
+ node.body.forEach((child: any) => traverse(child, currentParentPath));
1321
+ }
1322
+ if (node.containers && Array.isArray(node.containers)) {
1323
+ node.containers.forEach((child: any) =>
1324
+ traverse(child, currentParentPath)
1325
+ );
1326
+ }
1327
+ if (node.components && Array.isArray(node.components)) {
1328
+ node.components.forEach((child: any) =>
1329
+ traverse(child, currentParentPath)
1330
+ );
1331
+ }
1332
+ }
1333
+
1334
+ // Start traversal from page data
1335
+ const body =
1336
+ pageData.data?.body || pageData.body || (pageData as any).data?.body || [];
1337
+
1338
+ if (Array.isArray(body) && body.length > 0) {
1339
+ body.forEach((node: any) => traverse(node));
1340
+ }
1341
+
1342
+ // Qualify duplicate prop names
1343
+ qualifyActionButtons(buttons);
1344
+
1345
+ return buttons;
1346
+ }
1347
+
1348
+ /**
1349
+ * Qualifies action button prop names to ensure uniqueness.
1350
+ */
1351
+ function qualifyActionButtons(buttons: ActionButtonInfo[]): void {
1352
+ const propNameGroups = new Map<
1353
+ string,
1354
+ Array<ActionButtonInfo & { _parentPath: string[] }>
1355
+ >();
1356
+
1357
+ buttons.forEach((button) => {
1358
+ const buttonWithPath = button as ActionButtonInfo & {
1359
+ _parentPath: string[];
1360
+ };
1361
+ const baseName = button.propName;
1362
+ if (!propNameGroups.has(baseName)) {
1363
+ propNameGroups.set(baseName, []);
1364
+ }
1365
+ propNameGroups.get(baseName)!.push(buttonWithPath);
1366
+ });
1367
+
1368
+ propNameGroups.forEach((group, _baseName) => {
1369
+ if (group.length === 1) {
1370
+ delete (group[0] as any)._parentPath;
1371
+ return;
1372
+ }
1373
+
1374
+ // Find minimal distinguishing paths for duplicates
1375
+ group.forEach((button) => {
1376
+ const otherPaths = group
1377
+ .filter((b) => b.id !== button.id)
1378
+ .map((b) => b._parentPath || []);
1379
+
1380
+ const minimalPath = findMinimalDistinguishingPath(
1381
+ button._parentPath || [],
1382
+ otherPaths
1383
+ );
1384
+
1385
+ button.propName = generateQualifiedPropName(
1386
+ button.name || "button",
1387
+ minimalPath
1388
+ );
1389
+ });
1390
+
1391
+ // Clean up
1392
+ group.forEach((button) => {
1393
+ delete (button as any)._parentPath;
1394
+ });
1395
+ });
1396
+ }
1397
+
1398
+ /**
1399
+ * Qualifies form input prop names to ensure uniqueness within each form.
1400
+ * Only qualifies where necessary, using minimal distinguishing paths.
1401
+ */
1402
+ export function qualifyFormInputs(forms: FormInfo[]): void {
1403
+ forms.forEach((form) => {
1404
+ const inputs = form.inputs;
1405
+
1406
+ // Group inputs by base prop name
1407
+ const propNameGroups = new Map<
1408
+ string,
1409
+ Array<FormInfo["inputs"][0] & { _parentPath: string[] }>
1410
+ >();
1411
+
1412
+ inputs.forEach((input) => {
1413
+ const inputWithPath = input as FormInfo["inputs"][0] & {
1414
+ _parentPath: string[];
1415
+ };
1416
+ const baseName = input.propName;
1417
+ if (!propNameGroups.has(baseName)) {
1418
+ propNameGroups.set(baseName, []);
1419
+ }
1420
+ propNameGroups.get(baseName)!.push(inputWithPath);
1421
+ });
1422
+
1423
+ // For each group with duplicates, find minimal distinguishing paths
1424
+ propNameGroups.forEach((group, _baseName) => {
1425
+ if (group.length === 1) {
1426
+ // No duplicates, keep the simple name
1427
+ // Remove the temporary _parentPath property
1428
+ delete (group[0] as any)._parentPath;
1429
+ return;
1430
+ }
1431
+
1432
+ // Find minimal distinguishing paths for all inputs
1433
+ group.forEach((input) => {
1434
+ const otherPaths = group
1435
+ .filter((i) => i.id !== input.id)
1436
+ .map((i) => i._parentPath || []);
1437
+
1438
+ const minimalPath = findMinimalDistinguishingPath(
1439
+ input._parentPath || [],
1440
+ otherPaths
1441
+ );
1442
+
1443
+ // Use the minimal distinguishing path to qualify the name
1444
+ input.propName = generateQualifiedPropName(
1445
+ input.name || "input",
1446
+ minimalPath
1447
+ );
1448
+ });
1449
+
1450
+ // Check if qualified names are still duplicates and expand paths if needed
1451
+ let hasDuplicates = true;
1452
+ let iteration = 0;
1453
+ const maxIterations = 10; // Safety limit
1454
+
1455
+ while (hasDuplicates && iteration < maxIterations) {
1456
+ iteration++;
1457
+ const qualifiedNameGroups = new Map<
1458
+ string,
1459
+ Array<FormInfo["inputs"][0] & { _parentPath: string[] }>
1460
+ >();
1461
+ group.forEach((input) => {
1462
+ if (!qualifiedNameGroups.has(input.propName)) {
1463
+ qualifiedNameGroups.set(input.propName, []);
1464
+ }
1465
+ qualifiedNameGroups.get(input.propName)!.push(input);
1466
+ });
1467
+
1468
+ hasDuplicates = false;
1469
+ // For each group of still-duplicated qualified names, expand their paths
1470
+ qualifiedNameGroups.forEach((dupGroup, _qualifiedName) => {
1471
+ if (dupGroup.length > 1) {
1472
+ hasDuplicates = true;
1473
+ // Expand the distinguishing path for each duplicate
1474
+ dupGroup.forEach((input) => {
1475
+ const fullPath = input._parentPath || [];
1476
+ const otherFullPaths = dupGroup
1477
+ .filter((i) => i.id !== input.id)
1478
+ .map((i) => i._parentPath || []);
1479
+
1480
+ // Find where this path diverges from others in the duplicate group
1481
+ let commonPrefixLength = 0;
1482
+ const maxCommonLength = Math.min(
1483
+ fullPath.length,
1484
+ ...otherFullPaths.map((p) => p.length)
1485
+ );
1486
+
1487
+ for (let i = 0; i < maxCommonLength; i++) {
1488
+ const thisPart = fullPath[i];
1489
+ const allMatch = otherFullPaths.every((otherPath) => {
1490
+ return otherPath[i] === thisPart;
1491
+ });
1492
+ if (allMatch) {
1493
+ commonPrefixLength++;
1494
+ } else {
1495
+ break;
1496
+ }
1497
+ }
1498
+
1499
+ // Use progressively more of the distinguishing suffix until unique
1500
+ const distinguishingSuffix = fullPath.slice(commonPrefixLength);
1501
+
1502
+ // Try expanding the distinguishing suffix until we find a unique name
1503
+ let foundUnique = false;
1504
+ for (
1505
+ let suffixLength = 1;
1506
+ suffixLength <= distinguishingSuffix.length;
1507
+ suffixLength++
1508
+ ) {
1509
+ const expandedPath = distinguishingSuffix.slice(
1510
+ 0,
1511
+ suffixLength
1512
+ );
1513
+ const testQualifiedName = generateQualifiedPropName(
1514
+ input.name || "input",
1515
+ expandedPath
1516
+ );
1517
+
1518
+ // Check if this qualified name is unique among ALL inputs in this form
1519
+ const isUnique = inputs.every((otherInput) => {
1520
+ if (otherInput.id === input.id) return true;
1521
+ // If other input is in the same duplicate group, compare expanded paths
1522
+ if (dupGroup.some((i) => i.id === otherInput.id)) {
1523
+ const otherFullPath =
1524
+ (
1525
+ otherInput as FormInfo["inputs"][0] & {
1526
+ _parentPath: string[];
1527
+ }
1528
+ )._parentPath || [];
1529
+ const otherCommonPrefixLength = Math.min(
1530
+ commonPrefixLength,
1531
+ otherFullPath.length
1532
+ );
1533
+ const otherDistinguishingSuffix = otherFullPath.slice(
1534
+ otherCommonPrefixLength
1535
+ );
1536
+ const otherExpandedPath = otherDistinguishingSuffix.slice(
1537
+ 0,
1538
+ suffixLength
1539
+ );
1540
+ const otherQualifiedName = generateQualifiedPropName(
1541
+ otherInput.name || "input",
1542
+ otherExpandedPath
1543
+ );
1544
+ return testQualifiedName !== otherQualifiedName;
1545
+ }
1546
+ // For inputs outside the duplicate group, just check the final prop name
1547
+ return testQualifiedName !== otherInput.propName;
1548
+ });
1549
+
1550
+ if (isUnique) {
1551
+ input.propName = testQualifiedName;
1552
+ foundUnique = true;
1553
+ break;
1554
+ }
1555
+ }
1556
+
1557
+ // If we couldn't find a unique name with the distinguishing suffix,
1558
+ // use the distinguishing suffix we found (it's the minimal we can do)
1559
+ if (!foundUnique) {
1560
+ input.propName = generateQualifiedPropName(
1561
+ input.name || "input",
1562
+ distinguishingSuffix.length > 0 ? distinguishingSuffix : []
1563
+ );
1564
+ }
1565
+ });
1566
+ }
1567
+ });
1568
+ }
1569
+
1570
+ // Final check: if there are still duplicates after using full paths,
1571
+ // and they have identical paths, use numeric suffixes as last resort
1572
+ const finalQualifiedNameGroups = new Map<
1573
+ string,
1574
+ Array<FormInfo["inputs"][0] & { _parentPath: string[] }>
1575
+ >();
1576
+ group.forEach((input) => {
1577
+ if (!finalQualifiedNameGroups.has(input.propName)) {
1578
+ finalQualifiedNameGroups.set(input.propName, []);
1579
+ }
1580
+ finalQualifiedNameGroups.get(input.propName)!.push(input);
1581
+ });
1582
+
1583
+ finalQualifiedNameGroups.forEach((finalDupGroup, finalQualifiedName) => {
1584
+ if (finalDupGroup.length > 1) {
1585
+ // Check if all duplicates have identical paths
1586
+ const allPathsIdentical = finalDupGroup.every((input) => {
1587
+ const thisPath = input._parentPath || [];
1588
+ return finalDupGroup.every((otherInput) => {
1589
+ if (otherInput.id === input.id) return true;
1590
+ const otherPath = otherInput._parentPath || [];
1591
+ return arraysEqual(thisPath, otherPath);
1592
+ });
1593
+ });
1594
+
1595
+ // Only use numeric suffixes if paths are truly identical
1596
+ if (allPathsIdentical) {
1597
+ let index = 0;
1598
+ finalDupGroup.forEach((input) => {
1599
+ if (index > 0) {
1600
+ input.propName = `${finalQualifiedName}${index + 1}`;
1601
+ }
1602
+ index++;
1603
+ });
1604
+ }
1605
+ }
1606
+ });
1607
+
1608
+ // Remove the temporary _parentPath property
1609
+ group.forEach((input) => {
1610
+ delete (input as any)._parentPath;
1611
+ });
1612
+ });
1613
+ });
1614
+ }