@deneb-ui/cli 2.0.50 → 2.0.52

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.
@@ -277,8 +277,50 @@ function scanPagesRouterRoutes(pagesDir, projectDir) {
277
277
  * ever declares routes it actually found.
278
278
  */
279
279
  function keepExportableRoutes(routes) {
280
- const exportable = routes.filter((route) => !route.dynamic);
281
- return exportable.length ? exportable : routes;
280
+ const exportable = routes.filter((route) => !route.dynamic && (route.file || route.inferred));
281
+ return exportable.length ? exportable : routes.filter((route) => route.inferred);
282
+ }
283
+
284
+ function resolvePageFileOnDisk(projectDir, page) {
285
+ if (!page) return null;
286
+ if (page.file && fs.existsSync(path.join(projectDir, page.file))) return page.file.replace(/\\/g, '/');
287
+ const route = String(page.route || '').replace(/^\//, '').replace(/\/+$/, '');
288
+ const id = page.id === 'home' ? '' : page.id;
289
+ const segments = route || id || '';
290
+ const candidates = [];
291
+ if (!segments || page.id === 'home' || page.route === '/') {
292
+ candidates.push(
293
+ 'src/app/page.tsx',
294
+ 'src/app/page.jsx',
295
+ 'src/app/page.js',
296
+ 'app/page.tsx',
297
+ 'app/page.jsx',
298
+ 'app/page.js',
299
+ 'pages/index.tsx',
300
+ 'pages/index.jsx',
301
+ 'pages/index.js',
302
+ 'src/pages/index.tsx',
303
+ 'src/pages/index.jsx',
304
+ 'src/pages/index.js',
305
+ );
306
+ }
307
+ if (segments) {
308
+ for (const base of ['src/app', 'app']) {
309
+ for (const name of ['page.tsx', 'page.jsx', 'page.js']) {
310
+ candidates.push(`${base}/${segments}/${name}`);
311
+ }
312
+ }
313
+ for (const base of ['pages', 'src/pages']) {
314
+ for (const ext of ['tsx', 'jsx', 'js']) {
315
+ candidates.push(`${base}/${segments}.${ext}`);
316
+ candidates.push(`${base}/${segments}/index.${ext}`);
317
+ }
318
+ }
319
+ }
320
+ for (const relative of candidates) {
321
+ if (fs.existsSync(path.join(projectDir, relative))) return relative.replace(/\\/g, '/');
322
+ }
323
+ return null;
282
324
  }
283
325
 
284
326
  function detectLanguage(sourceFiles) {
@@ -610,4 +652,6 @@ module.exports = {
610
652
  resolveImportSpecifier,
611
653
  extractImportSpecifiers,
612
654
  parseTsconfig,
655
+ keepExportableRoutes,
656
+ resolvePageFileOnDisk,
613
657
  };
@@ -23,6 +23,8 @@ const {
23
23
  TEXT_TAGS,
24
24
  ACTION_TAGS,
25
25
  IMAGE_TAGS,
26
+ FORM_INPUT_TAGS,
27
+ FORM_CONTAINER_TAGS,
26
28
  DECORATIVE_TAGS,
27
29
  } = require('./adapters.cjs');
28
30
  const { shortHash } = require('./fs-utils.cjs');
@@ -126,8 +128,9 @@ function objectLiteralToPlain(node) {
126
128
  out[key] = val.quasis?.map((q) => q.value?.cooked || q.value?.raw || '').join('') || '';
127
129
  } else if (val.type === 'Identifier') {
128
130
  if (/^[A-Z]/.test(val.name)) {
129
- // Component references (e.g. icon: Truck, Icon: ShieldCheck) are not merchant content
130
- return null;
131
+ // Component references (e.g. icon: Truck) are not merchant content.
132
+ // Skip this key only so primitive siblings (title, body) stay convertible.
133
+ continue;
131
134
  }
132
135
  out[key] = val.name;
133
136
  } else if (val.type === 'UnaryExpression' && val.argument) {
@@ -347,12 +350,15 @@ function collectItemFieldUsage(callback, itemParam) {
347
350
  }
348
351
 
349
352
  let usesItemAsComponent = false;
353
+ const componentProps = new Set();
350
354
  recast.types.visit(callback, {
351
355
  visitJSXOpeningElement(pathNode) {
352
356
  const name = pathNode.node.name;
353
357
  if (name?.type === 'JSXMemberExpression') {
354
- if (name.object?.type === 'Identifier' && name.object.name === itemParam) {
358
+ const objectName = name.object && name.object.name;
359
+ if ((name.object?.type === 'Identifier' || name.object?.type === 'JSXIdentifier') && objectName === itemParam) {
355
360
  usesItemAsComponent = true;
361
+ if (name.property?.name) componentProps.add(name.property.name);
356
362
  }
357
363
  }
358
364
  this.traverse(pathNode);
@@ -360,6 +366,7 @@ function collectItemFieldUsage(callback, itemParam) {
360
366
  visitJSXExpressionContainer(pathNode) {
361
367
  const properties = findItemMemberProperties(pathNode.node.expression);
362
368
  for (const property of properties) {
369
+ if (componentProps.has(property)) continue;
363
370
  const parent = pathNode.parent?.node || pathNode.parent?.value;
364
371
  if (parent?.type === 'JSXAttribute') {
365
372
  const attribute = parent.name?.name;
@@ -392,7 +399,7 @@ function confidenceFor(kind, extras = {}) {
392
399
  if (extras.icon) return 0.1;
393
400
  if (kind === 'url' && extras.action === 'whatsapp') return 0.96;
394
401
  if (kind === 'url' && extras.social) return 0.93;
395
- if (kind === 'split-action-contract') return 0.94;
402
+ if (kind === 'split-action-contract' || kind === 'form-submit-action') return 0.94;
396
403
  if (kind === 'text' && HEADING_TAGS.has(extras.tag)) return 0.95;
397
404
  if (kind === 'text' && extras.tag === 'p') return 0.9;
398
405
  if (kind === 'image') return 0.88;
@@ -562,14 +569,47 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
562
569
  });
563
570
  }
564
571
 
572
+ const insideForm = parents.some((p) => FORM_CONTAINER_TAGS.has(p));
573
+
565
574
  const actionableHref = href || (name === 'Button' ? getJsxAttributeLiteral(node, 'href') : null);
566
575
  const innerText = textInfo.dynamic ? '' : textInfo.text;
567
576
  const typeAttr = getJsxAttributeLiteral(node, 'type');
568
577
  const isSubmit = typeAttr === 'submit';
569
578
 
570
- // Check for action intent via classifyActionIntent (covers WhatsApp, Call/Phone, Directions, Location, Shop, Email)
571
- const actionIntent = !isSubmit && !apiOwned ? classifyActionIntent(innerText, actionableHref) : null;
572
- const isAction = (Boolean(actionableHref) || Boolean(actionIntent)) && (ACTION_TAGS.has(name) || isLikelyCtaClass(className));
579
+ // Check for action intent via classifyActionIntent (covers Form Submit, WhatsApp, Call/Phone, Directions, Location, Shop, Email)
580
+ const actionIntent = !apiOwned ? classifyActionIntent(innerText, actionableHref) : null;
581
+
582
+ const isFormSubmit =
583
+ !apiOwned &&
584
+ (ACTION_TAGS.has(name) || isLikelyCtaClass(className)) &&
585
+ (isSubmit ||
586
+ (insideForm && (actionIntent?.action === 'form-submit' || (innerText && !actionableHref))) ||
587
+ actionIntent?.action === 'form-submit');
588
+
589
+ if (isFormSubmit && innerText && !textInfo.dynamic) {
590
+ usedLocs.add(loc);
591
+ const resolvedHref = actionableHref || (actionIntent ? actionIntent.defaultUrl : 'https://wa.me/1234567890');
592
+ candidates.push({
593
+ ...baseMeta,
594
+ kind: 'form-submit-action',
595
+ operation: 'form-submit-action',
596
+ value: resolvedHref,
597
+ label: innerText,
598
+ extra: {
599
+ action: 'form-submit',
600
+ external: true,
601
+ insideForm,
602
+ isSubmit,
603
+ },
604
+ confidence: confidenceFor('form-submit-action'),
605
+ reason: 'form-submit-whatsapp-dispatch',
606
+ fingerprint: fingerprintCandidate({ tag: name, kind: 'form-submit-action', action: 'form-submit' }),
607
+ });
608
+ this.traverse(pathNode);
609
+ return;
610
+ }
611
+
612
+ const isAction = !isSubmit && (Boolean(actionableHref) || Boolean(actionIntent)) && (ACTION_TAGS.has(name) || isLikelyCtaClass(className));
573
613
 
574
614
  if (isAction && (actionableHref || actionIntent)) {
575
615
  const action = actionIntent ? actionIntent.action : classifyHref(actionableHref);
@@ -684,7 +724,6 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
684
724
  const objectItems = arr.items.every((item) => item.type === 'object');
685
725
  const boundProperties = [...itemUsage.keys()];
686
726
  const convertible =
687
- !usesItemAsComponent &&
688
727
  objectItems &&
689
728
  boundProperties.length > 0 &&
690
729
  arr.items.some((item) => boundProperties.some((key) => key in item.value));
@@ -5,6 +5,7 @@ const BUTTON_TAGS = new Set(['button', 'Button', 'CTAButton']);
5
5
 
6
6
  function inferStyleKind(transform) {
7
7
  if (transform.operation === 'collection-conversion') return 'grid';
8
+ if (transform.operation === 'form-submit-action') return 'button';
8
9
  if (transform.operation === 'split-action-contract') return 'text';
9
10
  if (BUTTON_TAGS.has(transform.tag) && transform.operation === 'extract-text') return 'button';
10
11
  if (TEXT_OPS.has(transform.operation)) return 'text';
@@ -14,7 +15,7 @@ function inferStyleKind(transform) {
14
15
  function stylePathFor(transform, kind) {
15
16
  if (kind === 'grid' && transform.listField) return `${transform.listField}.grid`;
16
17
  if (kind === 'card' && transform.listField) return `${transform.listField}[*].card`;
17
- if (transform.operation === 'split-action-contract') return transform.labelField;
18
+ if (transform.operation === 'split-action-contract' || transform.operation === 'form-submit-action') return transform.labelField;
18
19
  return transform.field || transform.labelField || null;
19
20
  }
20
21
 
@@ -152,6 +152,32 @@ function wrapHiddenUrlSibling(pathNode, urlField, fallback) {
152
152
 
153
153
  function applyTransformToElement(pathNode, transform) {
154
154
  const node = pathNode.node;
155
+ if (transform.operation === 'form-submit-action') {
156
+ const tagName = getJsxName(node);
157
+ if (!hasJsxAttribute(node, 'type') && tagName === 'button') {
158
+ node.openingElement.attributes.push(
159
+ b.jsxAttribute(b.jsxIdentifier('type'), b.stringLiteral('submit'))
160
+ );
161
+ }
162
+ if (transform.labelField) {
163
+ if (hasJsxAttribute(node, 'data-preview-static')) {
164
+ node.openingElement.attributes = node.openingElement.attributes.filter(
165
+ (attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'data-preview-static')
166
+ );
167
+ }
168
+ if (hasJsxAttribute(node, 'data-preview-field-path')) {
169
+ node.openingElement.attributes = node.openingElement.attributes.filter(
170
+ (attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'data-preview-field-path')
171
+ );
172
+ }
173
+ splitActionChildren(node, transform.labelField, transform.labelFallback || '');
174
+ } else if (!hasJsxAttribute(node, 'data-preview-static')) {
175
+ node.openingElement.attributes.push(
176
+ b.jsxAttribute(b.jsxIdentifier('data-preview-static'), b.stringLiteral('action-button'))
177
+ );
178
+ }
179
+ return;
180
+ }
155
181
  if (transform.operation === 'split-action-contract') {
156
182
  const tagName = getJsxName(node);
157
183
  if (tagName === 'button') {
@@ -185,18 +211,17 @@ function applyTransformToElement(pathNode, transform) {
185
211
  }
186
212
  }
187
213
 
188
- if (!hasJsxAttribute(node, 'data-preview-static')) {
189
- node.openingElement.attributes.push(
190
- b.jsxAttribute(b.jsxIdentifier('data-preview-static'), b.stringLiteral('action-link'))
191
- );
192
- }
193
- if (hasJsxAttribute(node, 'data-preview-field-path')) {
214
+ // Fivora Strict Action/Label Contract:
215
+ // Bind the URL action marker directly to the outer <a> element (never hidden)
216
+ replaceAttrValue(node, 'data-preview-field-path', b.stringLiteral(transform.urlField));
217
+ // Remove data-preview-static if present to prevent static/field collisions
218
+ if (hasJsxAttribute(node, 'data-preview-static')) {
194
219
  node.openingElement.attributes = node.openingElement.attributes.filter(
195
- (attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'data-preview-field-path')
220
+ (attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'data-preview-static')
196
221
  );
197
222
  }
223
+ // Bind the label to the inner text child
198
224
  splitActionChildren(node, transform.labelField, transform.labelFallback || '');
199
- wrapHiddenUrlSibling(pathNode, transform.urlField, transform.fallback);
200
225
  return;
201
226
  }
202
227
  if (transform.operation === 'extract-url') {
@@ -234,11 +259,29 @@ function applyTransformToElement(pathNode, transform) {
234
259
  return;
235
260
  }
236
261
  if (transform.operation === 'style-bind') {
237
- if (transform.styleKind === 'grid' || transform.styleKind === 'card') return;
262
+ if (transform.styleKind === 'grid') {
263
+ const container = findAncestorJsxElement(pathNode) || node;
264
+ ensureStyleAttrs(container, transform.stylePath, 'grid');
265
+ return;
266
+ }
267
+ if (transform.styleKind === 'card') {
268
+ ensureStyleAttrs(node, transform.stylePath, 'card');
269
+ return;
270
+ }
238
271
  ensureStyleAttrs(node, transform.stylePath, transform.styleKind || 'text');
239
272
  }
240
273
  }
241
274
 
275
+ function findAncestorJsxElement(pathNode) {
276
+ let current = pathNode.parent;
277
+ while (current) {
278
+ const candidate = current.node || current.value;
279
+ if (candidate?.type === 'JSXElement') return candidate;
280
+ current = current.parentPath || current.parent;
281
+ }
282
+ return null;
283
+ }
284
+
242
285
  function inferButtonKind(tag) {
243
286
  if (tag === 'button' || tag === 'Button') return 'button';
244
287
  return 'text';
@@ -314,6 +357,7 @@ function applyCollectionTransform(ast, transform, isClient = false) {
314
357
  }
315
358
 
316
359
  markItemFields(callback, { listPath, binding, indexName });
360
+ markComponentRefItemsStatic(callback, binding);
317
361
 
318
362
  const container = findListContainer(mapCall);
319
363
  if (container && !hasJsxAttribute(container, 'data-preview-list-path')) {
@@ -330,7 +374,7 @@ function applyCollectionTransform(ast, transform, isClient = false) {
330
374
  );
331
375
  }
332
376
 
333
- return bindArrayDeclaration(ast, mapCall, listPath, isClient);
377
+ return bindArrayDeclaration(ast, mapCall, listPath, isClient, Boolean(transform.hasComponentRef));
334
378
  }
335
379
 
336
380
  function findMapCall(ast, loc) {
@@ -389,9 +433,14 @@ function markItemFields(callback, { listPath, binding, indexName }) {
389
433
  );
390
434
  if (textChild && !hasJsxAttribute(node, 'data-preview-field-path')) {
391
435
  const property = itemMemberName(textChild.expression, binding);
392
- node.openingElement.attributes.push(
393
- jsxTemplatePathAttr('data-preview-field-path', listPath, indexName, `.${property}`)
394
- );
436
+ const tagName = getJsxName(node);
437
+ if (BROAD_CONTENT_CONTAINERS.has(tagName) || BROAD_CONTENT_CONTAINERS.has(String(tagName).toLowerCase())) {
438
+ wrapItemFieldInSpan(node, textChild, listPath, indexName, property);
439
+ } else {
440
+ node.openingElement.attributes.push(
441
+ jsxTemplatePathAttr('data-preview-field-path', listPath, indexName, `.${property}`)
442
+ );
443
+ }
395
444
  }
396
445
 
397
446
  this.traverse(pathNode);
@@ -399,6 +448,51 @@ function markItemFields(callback, { listPath, binding, indexName }) {
399
448
  });
400
449
  }
401
450
 
451
+ function wrapItemFieldInSpan(node, textChild, listPath, indexName, property) {
452
+ const nextChildren = [];
453
+ for (const child of node.children || []) {
454
+ if (child === textChild) {
455
+ nextChildren.push(
456
+ b.jsxElement(
457
+ b.jsxOpeningElement(
458
+ b.jsxIdentifier('span'),
459
+ [jsxTemplatePathAttr('data-preview-field-path', listPath, indexName, `.${property}`)],
460
+ false
461
+ ),
462
+ b.jsxClosingElement(b.jsxIdentifier('span')),
463
+ [child],
464
+ false
465
+ )
466
+ );
467
+ } else {
468
+ nextChildren.push(child);
469
+ }
470
+ }
471
+ node.children = nextChildren;
472
+ }
473
+
474
+ function markComponentRefItemsStatic(callback, binding) {
475
+ recast.types.visit(callback, {
476
+ visitJSXElement(pathNode) {
477
+ const name = pathNode.node.openingElement && pathNode.node.openingElement.name;
478
+ if (
479
+ name &&
480
+ name.type === 'JSXMemberExpression' &&
481
+ name.object &&
482
+ (name.object.type === 'Identifier' || name.object.type === 'JSXIdentifier') &&
483
+ name.object.name === binding
484
+ ) {
485
+ if (!hasJsxAttribute(pathNode.node, 'data-preview-static') && !hasJsxAttribute(pathNode.node, 'data-preview-field-path')) {
486
+ pathNode.node.openingElement.attributes.push(
487
+ b.jsxAttribute(b.jsxIdentifier('data-preview-static'), b.stringLiteral('component-ref'))
488
+ );
489
+ }
490
+ }
491
+ this.traverse(pathNode);
492
+ },
493
+ });
494
+ }
495
+
402
496
  function itemMemberName(expr, binding) {
403
497
  if (!expr || expr.type !== 'MemberExpression' || expr.computed) return null;
404
498
  if (expr.object?.type !== 'Identifier' || expr.object.name !== binding) return null;
@@ -449,7 +543,7 @@ function findEnclosingFunction(pathNode) {
449
543
  return null;
450
544
  }
451
545
 
452
- function bindArrayDeclaration(ast, mapCallPath, listPath, isClient = false) {
546
+ function bindArrayDeclaration(ast, mapCallPath, listPath, isClient = false, mergeDefaultRefs = false) {
453
547
  const arrayName = mapCallPath.node.callee.object?.name;
454
548
  if (!arrayName) return false;
455
549
  let bound = false;
@@ -477,7 +571,9 @@ function bindArrayDeclaration(ast, mapCallPath, listPath, isClient = false) {
477
571
  const localDecl = b.variableDeclaration('const', [
478
572
  b.variableDeclarator(
479
573
  b.identifier(arrayName),
480
- siteDataListBinding(listPath.split('.'), b.identifier(defaultName))
574
+ mergeDefaultRefs
575
+ ? mergeDefaultItemRefsBinding(listPath.split('.'), defaultName)
576
+ : siteDataListBinding(listPath.split('.'), b.identifier(defaultName))
481
577
  ),
482
578
  ]);
483
579
  const hookIdx = body.findIndex((stmt) => recast.print(stmt).code.includes('useSiteData'));
@@ -501,6 +597,28 @@ function bindArrayDeclaration(ast, mapCallPath, listPath, isClient = false) {
501
597
  return bound;
502
598
  }
503
599
 
600
+ function mergeDefaultItemRefsBinding(pathParts, defaultName) {
601
+ const listExpr = siteDataListBinding(pathParts, b.identifier(defaultName));
602
+ return b.callExpression(
603
+ b.memberExpression(listExpr, b.identifier('map'), false),
604
+ [
605
+ b.arrowFunctionExpression(
606
+ [b.identifier('item'), b.identifier('index')],
607
+ b.objectExpression([
608
+ b.spreadElement(
609
+ b.logicalExpression(
610
+ '??',
611
+ b.memberExpression(b.identifier(defaultName), b.identifier('index'), true),
612
+ b.objectExpression([])
613
+ )
614
+ ),
615
+ b.spreadElement(b.identifier('item')),
616
+ ])
617
+ ),
618
+ ]
619
+ );
620
+ }
621
+
504
622
  function fileAlreadyUsesSiteDataHook(ast) {
505
623
  let found = false;
506
624
  recast.types.visit(ast, {
@@ -625,6 +743,7 @@ function applyFilePlan(filePlan, profile) {
625
743
 
626
744
  const supported = new Set([
627
745
  'split-action-contract',
746
+ 'form-submit-action',
628
747
  'extract-url',
629
748
  'extract-image',
630
749
  'extract-alt',
@@ -688,6 +807,9 @@ function applyFilePlan(filePlan, profile) {
688
807
 
689
808
  // Sanitize any conflicting data-preview-static on elements with editable markers
690
809
  sanitizeContradictoryMarkers(ast);
810
+ healBroadContainerMarkers(ast);
811
+ healEmptyStateConditionals(ast);
812
+ healHiddenPreviewMarkers(ast);
691
813
 
692
814
  // Page keys are stamped in a separate route-driven pass so App Router and
693
815
  // Pages Router projects are handled by the same logic.
@@ -972,8 +1094,7 @@ function sanitizeContradictoryMarkers(ast) {
972
1094
  }
973
1095
 
974
1096
  function sanitizeContradictoryMarkersInSource(code, relativeFile) {
975
- if (!code.includes('data-preview-static')) return { code, updated: false };
976
- if (!code.includes('data-preview-field-path') && !code.includes('data-preview-list-path') && !code.includes('data-preview-item-path')) {
1097
+ if (!code.includes('data-preview-static') && !code.includes('data-preview-field-path')) {
977
1098
  return { code, updated: false };
978
1099
  }
979
1100
  let ast;
@@ -982,9 +1103,200 @@ function sanitizeContradictoryMarkersInSource(code, relativeFile) {
982
1103
  } catch {
983
1104
  return { code, updated: false };
984
1105
  }
985
- const count = sanitizeContradictoryMarkers(ast);
986
- if (count === 0) return { code, updated: false };
987
- return { code: printSource(ast, code), updated: true, count };
1106
+ const cleaned = sanitizeContradictoryMarkers(ast);
1107
+ const healedBroad = healBroadContainerMarkers(ast);
1108
+ const healedEmpty = healEmptyStateConditionals(ast);
1109
+ const healedHidden = healHiddenPreviewMarkers(ast);
1110
+ if (cleaned === 0 && healedBroad === 0 && healedEmpty === 0 && healedHidden === 0) return { code, updated: false };
1111
+ return { code: printSource(ast, code), updated: true, count: cleaned + healedBroad + healedEmpty + healedHidden };
1112
+ }
1113
+
1114
+ function healBroadContainerMarkers(ast) {
1115
+ let healed = 0;
1116
+ recast.types.visit(ast, {
1117
+ visitJSXElement(pathNode) {
1118
+ const node = pathNode.node;
1119
+ const tag = getJsxName(node);
1120
+ const lower = String(tag || '').toLowerCase();
1121
+ if (!BROAD_CONTENT_CONTAINERS.has(tag) && !BROAD_CONTENT_CONTAINERS.has(lower)) {
1122
+ this.traverse(pathNode);
1123
+ return;
1124
+ }
1125
+ const fieldAttr = findJsxAttribute(node, 'data-preview-field-path');
1126
+ if (!fieldAttr) {
1127
+ this.traverse(pathNode);
1128
+ return;
1129
+ }
1130
+
1131
+ const fieldValue = fieldAttr.value;
1132
+ node.openingElement.attributes = (node.openingElement.attributes || []).filter(
1133
+ (attr) =>
1134
+ !(
1135
+ attr.type === 'JSXAttribute' &&
1136
+ attr.name &&
1137
+ (attr.name.name === 'data-preview-field-path' ||
1138
+ attr.name.name === 'data-preview-style-target' ||
1139
+ attr.name.name === 'data-preview-style-type')
1140
+ )
1141
+ );
1142
+
1143
+ const leaf = findFirstLeafChild(node);
1144
+ if (leaf && !hasJsxAttribute(leaf, 'data-preview-field-path')) {
1145
+ leaf.openingElement.attributes.push(
1146
+ b.jsxAttribute(b.jsxIdentifier('data-preview-field-path'), fieldValue)
1147
+ );
1148
+ healed++;
1149
+ } else {
1150
+ const span = b.jsxElement(
1151
+ b.jsxOpeningElement(
1152
+ b.jsxIdentifier('span'),
1153
+ [b.jsxAttribute(b.jsxIdentifier('data-preview-field-path'), fieldValue)],
1154
+ false
1155
+ ),
1156
+ b.jsxClosingElement(b.jsxIdentifier('span')),
1157
+ node.children || [],
1158
+ false
1159
+ );
1160
+ node.children = [span];
1161
+ healed++;
1162
+ }
1163
+ this.traverse(pathNode);
1164
+ },
1165
+ });
1166
+ return healed;
1167
+ }
1168
+
1169
+ function findFirstLeafChild(node) {
1170
+ const leafTags = new Set(['span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'label', 'img', 'button', 'a', 'strong', 'em', 'small']);
1171
+ for (const child of node.children || []) {
1172
+ if (child && child.type === 'JSXElement') {
1173
+ const name = getJsxName(child);
1174
+ if (leafTags.has(name) || leafTags.has(String(name).toLowerCase())) return child;
1175
+ const nested = findFirstLeafChild(child);
1176
+ if (nested) return nested;
1177
+ }
1178
+ }
1179
+ return null;
1180
+ }
1181
+
1182
+ function healEmptyStateConditionals(ast) {
1183
+ let healed = 0;
1184
+
1185
+ function jsxHasPreviewMarker(node) {
1186
+ if (!node) return false;
1187
+ if (node.type === 'JSXElement') {
1188
+ if (
1189
+ hasJsxAttribute(node, 'data-preview-field-path') ||
1190
+ hasJsxAttribute(node, 'data-preview-list-path') ||
1191
+ hasJsxAttribute(node, 'data-preview-item-path')
1192
+ ) {
1193
+ return true;
1194
+ }
1195
+ return (node.children || []).some((child) => jsxHasPreviewMarker(child));
1196
+ }
1197
+ if (node.type === 'JSXFragment') {
1198
+ return (node.children || []).some((child) => jsxHasPreviewMarker(child));
1199
+ }
1200
+ if (node.type === 'ParenthesizedExpression') return jsxHasPreviewMarker(node.expression);
1201
+ return false;
1202
+ }
1203
+
1204
+ recast.types.visit(ast, {
1205
+ visitLogicalExpression(pathNode) {
1206
+ const expr = pathNode.node;
1207
+ if (expr.operator === '&&' && jsxHasPreviewMarker(expr.right)) {
1208
+ pathNode.replace(expr.right);
1209
+ healed++;
1210
+ return false;
1211
+ }
1212
+ this.traverse(pathNode);
1213
+ },
1214
+ visitJSXExpressionContainer(pathNode) {
1215
+ const expr = pathNode.node.expression;
1216
+ if (!expr) {
1217
+ this.traverse(pathNode);
1218
+ return;
1219
+ }
1220
+ if (expr.type === 'LogicalExpression' && expr.operator === '&&' && jsxHasPreviewMarker(expr.right)) {
1221
+ pathNode.node.expression = expr.right;
1222
+ healed++;
1223
+ return false;
1224
+ }
1225
+ if (
1226
+ expr.type === 'ConditionalExpression' &&
1227
+ jsxHasPreviewMarker(expr.consequent) &&
1228
+ (isNullish(expr.alternate) || isNullish(expr.consequent))
1229
+ ) {
1230
+ pathNode.node.expression = jsxHasPreviewMarker(expr.consequent) && !isNullish(expr.consequent)
1231
+ ? expr.consequent
1232
+ : expr.alternate;
1233
+ healed++;
1234
+ return false;
1235
+ }
1236
+ this.traverse(pathNode);
1237
+ },
1238
+ });
1239
+ return healed;
1240
+ }
1241
+
1242
+ function isNullish(node) {
1243
+ if (!node) return true;
1244
+ if (node.type === 'NullLiteral') return true;
1245
+ if (node.type === 'Literal' && (node.value === null || node.value === false || node.value === undefined)) return true;
1246
+ if (node.type === 'BooleanLiteral' && node.value === false) return true;
1247
+ if (node.type === 'Identifier' && (node.name === 'undefined' || node.name === 'null')) return true;
1248
+ return false;
1249
+ }
1250
+
1251
+ function healHiddenPreviewMarkers(ast) {
1252
+ let healed = 0;
1253
+ recast.types.visit(ast, {
1254
+ visitJSXOpeningElement(pathNode) {
1255
+ const attrs = pathNode.node.attributes || [];
1256
+ const hasEditable = attrs.some(
1257
+ (attr) =>
1258
+ attr.type === 'JSXAttribute' &&
1259
+ attr.name &&
1260
+ (attr.name.name === 'data-preview-field-path' ||
1261
+ attr.name.name === 'data-preview-list-path' ||
1262
+ attr.name.name === 'data-preview-item-path')
1263
+ );
1264
+ if (!hasEditable) {
1265
+ this.traverse(pathNode);
1266
+ return;
1267
+ }
1268
+ const classAttr = attrs.find((attr) => attr.type === 'JSXAttribute' && attr.name && (attr.name.name === 'className' || attr.name.name === 'class'));
1269
+ let classText = '';
1270
+ if (classAttr && classAttr.value) {
1271
+ if (classAttr.value.type === 'StringLiteral' || classAttr.value.type === 'Literal') classText = String(classAttr.value.value || '');
1272
+ else if (classAttr.value.type === 'JSXExpressionContainer' && classAttr.value.expression) {
1273
+ const expr = classAttr.value.expression;
1274
+ if (expr.type === 'StringLiteral' || expr.type === 'Literal') classText = String(expr.value || '');
1275
+ if (expr.type === 'TemplateLiteral') classText = (expr.quasis || []).map((q) => q.value.cooked || q.value.raw || '').join(' ');
1276
+ }
1277
+ }
1278
+ const hiddenAttr = attrs.some(
1279
+ (attr) => attr.type === 'JSXAttribute' && attr.name && (attr.name.name === 'hidden' || (attr.name.name === 'aria-hidden' && recast.print(attr).code.includes('true')))
1280
+ );
1281
+ if (hiddenAttr || /(^|\s)hidden(\s|$)/.test(classText)) {
1282
+ pathNode.node.attributes = attrs.filter(
1283
+ (attr) =>
1284
+ !(
1285
+ attr.type === 'JSXAttribute' &&
1286
+ attr.name &&
1287
+ (attr.name.name === 'data-preview-field-path' ||
1288
+ attr.name.name === 'data-preview-list-path' ||
1289
+ attr.name.name === 'data-preview-item-path' ||
1290
+ attr.name.name === 'data-preview-style-target' ||
1291
+ attr.name.name === 'data-preview-style-type')
1292
+ )
1293
+ );
1294
+ healed++;
1295
+ }
1296
+ this.traverse(pathNode);
1297
+ },
1298
+ });
1299
+ return healed;
988
1300
  }
989
1301
 
990
1302
  module.exports = {
@@ -998,4 +1310,7 @@ module.exports = {
998
1310
  inferPageKey,
999
1311
  sanitizeContradictoryMarkers,
1000
1312
  sanitizeContradictoryMarkersInSource,
1313
+ healBroadContainerMarkers,
1314
+ healEmptyStateConditionals,
1315
+ healHiddenPreviewMarkers,
1001
1316
  };