@deneb-ui/cli 2.0.70 → 2.0.72

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.
@@ -27,10 +27,14 @@ const { toPosix } = require('./fs-utils.cjs');
27
27
  const { BROAD_CONTENT_CONTAINERS } = require('./fivora-contract.cjs');
28
28
 
29
29
  function findElementByLoc(ast, loc) {
30
+ if (!loc) return null;
31
+ const parts = loc.split(':');
32
+ const targetLoc = parts.length > 4 ? parts.slice(0, 4).join(':') : loc;
30
33
  let found = null;
31
34
  recast.types.visit(ast, {
32
35
  visitJSXElement(pathNode) {
33
- if (locKey(pathNode.node) === loc) {
36
+ const k = locKey(pathNode.node);
37
+ if (k === targetLoc || k === loc) {
34
38
  found = pathNode;
35
39
  return false;
36
40
  }
@@ -243,6 +247,14 @@ function applyTransformToElement(pathNode, transform) {
243
247
  ensurePreviewPath(node, transform.field);
244
248
  return;
245
249
  }
250
+ if (transform.operation === 'extract-prop') {
251
+ const propName = transform.propName || 'title';
252
+ replaceAttrValue(node, propName, siteDataBinding(transform.field.split('.'), transform.fallback, transform.fieldType || 'text'));
253
+ if (!hasJsxAttribute(node, 'data-preview-field-path') && !hasJsxAttribute(node, 'data-preview-list-path')) {
254
+ ensurePreviewPath(node, transform.field);
255
+ }
256
+ return;
257
+ }
246
258
  if (transform.operation === 'wrap-text-span') {
247
259
  wrapLiteralTextChildren(node, transform.field, transform.fallback);
248
260
  return;
@@ -313,6 +325,20 @@ function wrapLiteralTextChildren(node, fieldPath, fallback) {
313
325
  if (wrapped) node.children = nextChildren;
314
326
  }
315
327
 
328
+ function buildCompositeKeyAttribute(binding, indexName) {
329
+ const fields = ['id', 'slug', 'title', 'name'];
330
+ let expr = b.memberExpression(b.identifier(binding), b.identifier(fields[0]), false);
331
+ for (let i = 1; i < fields.length; i++) {
332
+ expr = b.logicalExpression(
333
+ '||',
334
+ expr,
335
+ b.memberExpression(b.identifier(binding), b.identifier(fields[i]), false)
336
+ );
337
+ }
338
+ expr = b.logicalExpression('||', expr, b.identifier(indexName));
339
+ return b.jsxAttribute(b.jsxIdentifier('key'), b.jsxExpressionContainer(expr));
340
+ }
341
+
316
342
  /**
317
343
  * Converts a literal-array `.map()` render into a Fivora list contract.
318
344
  *
@@ -321,7 +347,7 @@ function wrapLiteralTextChildren(node, fieldPath, fallback) {
321
347
  * emitted as JSX template literals (`items[${index}].title`) so added and
322
348
  * reordered items stay editable, which is what strict mode requires.
323
349
  */
324
- function applyCollectionTransform(ast, transform, isClient = false) {
350
+ function applyCollectionTransform(ast, transform, isClient = false, isTypeScript = false) {
325
351
  const listPath = transform.listField;
326
352
  const binding = transform.itemParam;
327
353
  if (!listPath || !binding) return false;
@@ -335,8 +361,30 @@ function applyCollectionTransform(ast, transform, isClient = false) {
335
361
  // The contract needs a concrete index for each item marker.
336
362
  let indexName = transform.indexParam;
337
363
  if (!indexName) {
338
- indexName = callback.params.some((p) => p.name === 'index') ? 'denebIndex' : 'index';
339
- callback.params.push(b.identifier(indexName));
364
+ if (callback.params && callback.params.length >= 2 && callback.params[1]?.type === 'Identifier') {
365
+ indexName = callback.params[1].name;
366
+ } else {
367
+ indexName = callback.params.some((p) => p?.name === 'index') ? 'denebIndex' : 'index';
368
+ const indexId = b.identifier(indexName);
369
+ if (isTypeScript) {
370
+ indexId.typeAnnotation = b.tsTypeAnnotation(b.tsNumberKeyword());
371
+ }
372
+ callback.params.push(indexId);
373
+ }
374
+ }
375
+
376
+ // In TypeScript files, prevent TS7006 implicit any on callback parameters
377
+ if (isTypeScript && callback.params && callback.params.length > 0) {
378
+ const itemParam = callback.params[0];
379
+ if (itemParam && !itemParam.typeAnnotation) {
380
+ itemParam.typeAnnotation = b.tsTypeAnnotation(b.tsAnyKeyword());
381
+ }
382
+ if (callback.params.length >= 2) {
383
+ const idxParam = callback.params[1];
384
+ if (idxParam && !idxParam.typeAnnotation) {
385
+ idxParam.typeAnnotation = b.tsTypeAnnotation(b.tsNumberKeyword());
386
+ }
387
+ }
340
388
  }
341
389
 
342
390
  const itemRoot = findElementByLoc(ast, transform.loc);
@@ -355,6 +403,11 @@ function applyCollectionTransform(ast, transform, isClient = false) {
355
403
  b.jsxAttribute(b.jsxIdentifier('data-preview-style-type'), b.stringLiteral('card'))
356
404
  );
357
405
  }
406
+ if (!hasJsxAttribute(itemRoot.node, 'key')) {
407
+ itemRoot.node.openingElement.attributes.push(
408
+ buildCompositeKeyAttribute(binding, indexName)
409
+ );
410
+ }
358
411
 
359
412
  markItemFields(callback, { listPath, binding, indexName });
360
413
  markComponentRefItemsStatic(callback, binding);
@@ -374,7 +427,7 @@ function applyCollectionTransform(ast, transform, isClient = false) {
374
427
  );
375
428
  }
376
429
 
377
- return bindArrayDeclaration(ast, mapCall, listPath, isClient, Boolean(transform.hasComponentRef));
430
+ return bindArrayDeclaration(ast, mapCall, listPath, isClient, Boolean(transform.hasComponentRef), isTypeScript);
378
431
  }
379
432
 
380
433
  function findMapCall(ast, loc) {
@@ -395,7 +448,7 @@ function findMapCall(ast, loc) {
395
448
  hit = true;
396
449
  return false;
397
450
  }
398
- inner.traverse(inner);
451
+ this.traverse(inner);
399
452
  },
400
453
  });
401
454
  if (hit) {
@@ -543,7 +596,7 @@ function findEnclosingFunction(pathNode) {
543
596
  return null;
544
597
  }
545
598
 
546
- function bindArrayDeclaration(ast, mapCallPath, listPath, isClient = false, mergeDefaultRefs = false) {
599
+ function bindArrayDeclaration(ast, mapCallPath, listPath, isClient = false, mergeDefaultRefs = false, isTypeScript = false) {
547
600
  const arrayName = mapCallPath.node.callee.object?.name;
548
601
  if (!arrayName) return false;
549
602
  let bound = false;
@@ -568,9 +621,13 @@ function bindArrayDeclaration(ast, mapCallPath, listPath, isClient = false, merg
568
621
  const body = fnPath.node.body.body;
569
622
  const already = body.some((stmt) => recast.print(stmt).code.includes(`const ${arrayName} =`));
570
623
  if (!already) {
624
+ const localId = b.identifier(arrayName);
625
+ if (isTypeScript) {
626
+ localId.typeAnnotation = b.tsTypeAnnotation(b.tsArrayType(b.tsAnyKeyword()));
627
+ }
571
628
  const localDecl = b.variableDeclaration('const', [
572
629
  b.variableDeclarator(
573
- b.identifier(arrayName),
630
+ localId,
574
631
  mergeDefaultRefs
575
632
  ? mergeDefaultItemRefsBinding(listPath.split('.'), defaultName)
576
633
  : siteDataListBinding(listPath.split('.'), b.identifier(defaultName))
@@ -588,6 +645,9 @@ function bindArrayDeclaration(ast, mapCallPath, listPath, isClient = false, merg
588
645
  return false;
589
646
  }
590
647
 
648
+ if (isTypeScript && node.id?.type === 'Identifier' && !node.id.typeAnnotation) {
649
+ node.id.typeAnnotation = b.tsTypeAnnotation(b.tsArrayType(b.tsAnyKeyword()));
650
+ }
591
651
  node.init = siteDataListBinding(listPath.split('.'), node.init);
592
652
  bound = true;
593
653
  return false;
@@ -656,15 +716,41 @@ function resolveSiteDataRuntimeSpecifier(profile) {
656
716
  return '@deneb-ui/ui';
657
717
  }
658
718
 
719
+ function functionReferencesSiteData(fn) {
720
+ if (!fn || !fn.body) return false;
721
+ let found = false;
722
+ recast.types.visit(fn.body, {
723
+ visitIdentifier(pathNode) {
724
+ if (pathNode.node.name === 'siteData') {
725
+ found = true;
726
+ return false;
727
+ }
728
+ this.traverse(pathNode);
729
+ },
730
+ });
731
+ return found;
732
+ }
733
+
659
734
  function injectSiteDataHook(ast) {
660
- const program = ast.program || ast;
661
735
  let injected = false;
736
+ let anyFunctionReferencedSiteData = false;
737
+ let alreadyHasUseSiteData = false;
738
+
739
+ recast.types.visit(ast, {
740
+ visitCallExpression(pathNode) {
741
+ if (pathNode.node.callee && pathNode.node.callee.name === 'useSiteData') {
742
+ alreadyHasUseSiteData = true;
743
+ return false;
744
+ }
745
+ this.traverse(pathNode);
746
+ },
747
+ });
662
748
 
663
749
  function injectIntoFunction(fn) {
664
750
  if (!fn || !fn.body || fn.body.type !== 'BlockStatement') return false;
665
751
  const body = fn.body.body;
666
752
  const already = body.some((stmt) => recast.print(stmt).code.includes('useSiteData'));
667
- if (already) return true;
753
+ if (already) return false;
668
754
  const hook = b.variableDeclaration('const', [
669
755
  b.variableDeclarator(
670
756
  b.identifier('siteData'),
@@ -672,40 +758,70 @@ function injectSiteDataHook(ast) {
672
758
  ),
673
759
  ]);
674
760
  body.unshift(hook);
761
+ alreadyHasUseSiteData = true;
675
762
  return true;
676
763
  }
677
764
 
765
+ // Pass 1: Inject useSiteData() into EVERY component function that references siteData
678
766
  recast.types.visit(ast, {
679
- visitExportDefaultDeclaration(pathNode) {
680
- if (injected) return false;
681
- const decl = pathNode.node.declaration;
682
- if (decl && (decl.type === 'FunctionDeclaration' || decl.type === 'ArrowFunctionExpression' || decl.type === 'FunctionExpression')) {
683
- injected = injectIntoFunction(decl);
767
+ visitFunctionDeclaration(pathNode) {
768
+ if (functionReferencesSiteData(pathNode.node)) {
769
+ anyFunctionReferencedSiteData = true;
770
+ if (injectIntoFunction(pathNode.node)) injected = true;
684
771
  }
685
772
  this.traverse(pathNode);
686
773
  },
687
- visitFunctionDeclaration(pathNode) {
688
- if (injected) return false;
689
- const name = pathNode.node.id && pathNode.node.id.name;
690
- if (name && /^[A-Z]/.test(name)) {
691
- injected = injectIntoFunction(pathNode.node);
692
- return false;
774
+ visitFunctionExpression(pathNode) {
775
+ if (functionReferencesSiteData(pathNode.node)) {
776
+ anyFunctionReferencedSiteData = true;
777
+ if (injectIntoFunction(pathNode.node)) injected = true;
693
778
  }
694
779
  this.traverse(pathNode);
695
780
  },
696
- visitVariableDeclarator(pathNode) {
697
- if (injected) return false;
698
- const id = pathNode.node.id;
699
- const init = pathNode.node.init;
700
- if (id && id.type === 'Identifier' && /^[A-Z]/.test(id.name) && init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) {
701
- injected = injectIntoFunction(init);
702
- return false;
781
+ visitArrowFunctionExpression(pathNode) {
782
+ if (functionReferencesSiteData(pathNode.node)) {
783
+ anyFunctionReferencedSiteData = true;
784
+ if (injectIntoFunction(pathNode.node)) injected = true;
703
785
  }
704
786
  this.traverse(pathNode);
705
787
  },
706
788
  });
707
789
 
708
- if (!injected) {
790
+ // Pass 2: If no component references siteData yet (e.g. before subsequent AST edits),
791
+ // ensure the primary component receives useSiteData()
792
+ if (!injected && !anyFunctionReferencedSiteData && !alreadyHasUseSiteData) {
793
+ recast.types.visit(ast, {
794
+ visitExportDefaultDeclaration(pathNode) {
795
+ if (injected) return false;
796
+ const decl = pathNode.node.declaration;
797
+ if (decl && (decl.type === 'FunctionDeclaration' || decl.type === 'ArrowFunctionExpression' || decl.type === 'FunctionExpression')) {
798
+ injected = injectIntoFunction(decl);
799
+ }
800
+ this.traverse(pathNode);
801
+ },
802
+ visitFunctionDeclaration(pathNode) {
803
+ if (injected) return false;
804
+ const name = pathNode.node.id && pathNode.node.id.name;
805
+ if (name && /^[A-Z]/.test(name)) {
806
+ injected = injectIntoFunction(pathNode.node);
807
+ return false;
808
+ }
809
+ this.traverse(pathNode);
810
+ },
811
+ visitVariableDeclarator(pathNode) {
812
+ if (injected) return false;
813
+ const id = pathNode.node.id;
814
+ const init = pathNode.node.init;
815
+ if (id && id.type === 'Identifier' && /^[A-Z]/.test(id.name) && init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) {
816
+ injected = injectIntoFunction(init);
817
+ return false;
818
+ }
819
+ this.traverse(pathNode);
820
+ },
821
+ });
822
+ }
823
+
824
+ if (!injected && !anyFunctionReferencedSiteData && !alreadyHasUseSiteData) {
709
825
  recast.types.visit(ast, {
710
826
  visitFunctionDeclaration(pathNode) {
711
827
  if (injected) return false;
@@ -719,13 +835,15 @@ function injectSiteDataHook(ast) {
719
835
  }
720
836
 
721
837
  function resolveSiteDataSpecifier(profile, fromRelativeFile) {
722
- const aliases = profile.aliasMap || {};
838
+ const aliases = (profile && profile.aliasMap) || {};
723
839
  const hasAt = Object.keys(aliases).some((k) => k === '@/*' || k.startsWith('@/'));
724
- const siteDataRel = profile.hasSrc ? 'src/data/site-data.json' : 'data/site-data.json';
725
- if (hasAt && profile.hasSrc) return '@/data/site-data.json';
840
+ const hasSrc = Boolean(profile && profile.hasSrc);
841
+ const siteDataRel = hasSrc ? 'src/data/site-data.json' : 'data/site-data.json';
842
+ if (hasAt && hasSrc) return '@/data/site-data.json';
726
843
 
727
- const fromAbs = path.join(profile.root, fromRelativeFile);
728
- const toAbs = path.join(profile.root, siteDataRel);
844
+ const root = profile && profile.root ? profile.root : process.cwd();
845
+ const fromAbs = path.join(root, fromRelativeFile || 'page.tsx');
846
+ const toAbs = path.join(root, siteDataRel);
729
847
  let relSpec = path.relative(path.dirname(fromAbs), toAbs).replace(/\\/g, '/');
730
848
  if (!relSpec.startsWith('.')) relSpec = './' + relSpec;
731
849
  return relSpec;
@@ -749,11 +867,14 @@ function applyFilePlan(filePlan, profile) {
749
867
  'extract-alt',
750
868
  'extract-placeholder',
751
869
  'extract-text',
870
+ 'extract-prop',
752
871
  'wrap-text-span',
753
872
  'collection-conversion',
754
873
  'style-bind',
755
874
  ]);
756
875
 
876
+ const isTypeScript = Boolean(filePlan.file && /\.(tsx|ts)$/.test(filePlan.file));
877
+
757
878
  // Collections run first: they rewrite the array declaration and add an index
758
879
  // parameter, and later per-element edits must observe that shape.
759
880
  const ordered = [...filePlan.transformations].sort(
@@ -768,7 +889,7 @@ function applyFilePlan(filePlan, profile) {
768
889
 
769
890
  if (transform.operation === 'collection-conversion') {
770
891
  try {
771
- if (applyCollectionTransform(ast, transform, isClient)) applied++;
892
+ if (applyCollectionTransform(ast, transform, isClient, isTypeScript)) applied++;
772
893
  else failures.push({ loc: transform.loc, reason: 'collection-not-bindable' });
773
894
  } catch (err) {
774
895
  failures.push({ loc: transform.loc, reason: err.message });
@@ -796,9 +917,7 @@ function applyFilePlan(filePlan, profile) {
796
917
  const siteDataImport = resolveSiteDataSpecifier(profile, filePlan.file);
797
918
  if (isClient) {
798
919
  const runtimeSpecifier = resolveSiteDataRuntimeSpecifier(profile);
799
- if (!fileAlreadyUsesSiteDataHook(ast)) {
800
- injectSiteDataHook(ast);
801
- }
920
+ injectSiteDataHook(ast);
802
921
  ensureImport(ast, runtimeSpecifier, ['useSiteData']);
803
922
  } else {
804
923
  ensureDefaultImport(ast, siteDataImport, 'siteData');
@@ -1048,11 +1167,98 @@ function instrumentLayoutSource(code, siteDataImport, providerImport = '@deneb-u
1048
1167
 
1049
1168
  ensureProviderInitialData(ast, jsonIdent);
1050
1169
  injectPlatformAdditionalPages(ast, providerImport);
1170
+ injectDualModeTheme(ast, jsonIdent, providerImport);
1051
1171
  sanitizeDuplicateBindings(ast);
1052
1172
  const next = printSource(ast, code);
1053
1173
  return { code: next, updated: next !== code };
1054
1174
  }
1055
1175
 
1176
+ /**
1177
+ * Injects <ThemeStyles /> and <ThemeToggle /> into the layout JSX tree.
1178
+ * ThemeStyles applies light/dark variables and auto-contrast rules.
1179
+ * ThemeToggle provides an out-of-the-box floating theme switcher.
1180
+ */
1181
+ function injectDualModeTheme(ast, jsonIdent, providerImport = '@deneb-ui/ui') {
1182
+ if (!ast) return;
1183
+ let hasThemeStyles = false;
1184
+ let hasThemeToggle = false;
1185
+ recast.types.visit(ast, {
1186
+ visitJSXIdentifier(pathNode) {
1187
+ if (pathNode.node.name === 'ThemeStyles') hasThemeStyles = true;
1188
+ if (pathNode.node.name === 'ThemeToggle') hasThemeToggle = true;
1189
+ this.traverse(pathNode);
1190
+ },
1191
+ });
1192
+
1193
+ const importsToAdd = [];
1194
+ if (!hasThemeStyles) importsToAdd.push('ThemeStyles');
1195
+ if (!hasThemeToggle) importsToAdd.push('ThemeToggle');
1196
+ if (importsToAdd.length === 0) return;
1197
+
1198
+ if (!hasThemeStyles) {
1199
+ let stylesInjected = false;
1200
+ const snippet = parseSource(
1201
+ `<ThemeStyles theme={${jsonIdent}?.template?.structure?.theme || ${jsonIdent}?.theme} enableDualMode />`,
1202
+ 'snippet.tsx'
1203
+ );
1204
+ const stylesEl = snippet.program.body[0].expression;
1205
+
1206
+ // Try <head> first
1207
+ recast.types.visit(ast, {
1208
+ visitJSXElement(pathNode) {
1209
+ if (stylesInjected) return false;
1210
+ const name = getJsxName(pathNode.node);
1211
+ if (name === 'head' || name === 'Head') {
1212
+ pathNode.node.children = [b.jsxText('\n '), stylesEl, ...(pathNode.node.children || [])];
1213
+ stylesInjected = true;
1214
+ return false;
1215
+ }
1216
+ this.traverse(pathNode);
1217
+ },
1218
+ });
1219
+
1220
+ // If no <head>, inject inside SiteDataProvider or <body>
1221
+ if (!stylesInjected) {
1222
+ recast.types.visit(ast, {
1223
+ visitJSXElement(pathNode) {
1224
+ if (stylesInjected) return false;
1225
+ const name = getJsxName(pathNode.node);
1226
+ if (name === 'SiteDataProvider' || name === 'body') {
1227
+ pathNode.node.children = [b.jsxText('\n '), stylesEl, ...(pathNode.node.children || [])];
1228
+ stylesInjected = true;
1229
+ return false;
1230
+ }
1231
+ this.traverse(pathNode);
1232
+ },
1233
+ });
1234
+ }
1235
+ }
1236
+
1237
+ if (!hasThemeToggle) {
1238
+ let toggleInjected = false;
1239
+ const snippet = parseSource(
1240
+ '<ThemeToggle showLabel className="fixed bottom-6 left-6 z-40" />',
1241
+ 'snippet.tsx'
1242
+ );
1243
+ const toggleEl = snippet.program.body[0].expression;
1244
+
1245
+ recast.types.visit(ast, {
1246
+ visitJSXElement(pathNode) {
1247
+ if (toggleInjected) return false;
1248
+ const name = getJsxName(pathNode.node);
1249
+ if (name === 'SiteDataProvider' || name === 'body') {
1250
+ pathNode.node.children = [...(pathNode.node.children || []), b.jsxText('\n '), toggleEl];
1251
+ toggleInjected = true;
1252
+ return false;
1253
+ }
1254
+ this.traverse(pathNode);
1255
+ },
1256
+ });
1257
+ }
1258
+
1259
+ ensureImport(ast, providerImport || '@deneb-ui/ui', importsToAdd);
1260
+ }
1261
+
1056
1262
  /**
1057
1263
  * Injects <PlatformAdditionalPages /> into the layout JSX tree after <main>.
1058
1264
  * This component handles all additionalPages visual-editing markers correctly
@@ -1137,20 +1343,69 @@ function ensureJsonModule(tsconfig) {
1137
1343
  function sanitizeContradictoryMarkers(ast) {
1138
1344
  let cleaned = 0;
1139
1345
  recast.types.visit(ast, {
1140
- visitJSXOpeningElement(pathNode) {
1141
- const attrs = pathNode.node.attributes || [];
1142
- const hasEditable = attrs.some(
1346
+ visitJSXElement(pathNode) {
1347
+ const opening = pathNode.node.openingElement;
1348
+ if (!opening || !Array.isArray(opening.attributes)) {
1349
+ this.traverse(pathNode);
1350
+ return;
1351
+ }
1352
+ const attrs = opening.attributes;
1353
+ const hasStatic = attrs.some(
1354
+ (a) => a.type === 'JSXAttribute' && a.name && a.name.name === 'data-preview-static'
1355
+ );
1356
+ if (!hasStatic) {
1357
+ this.traverse(pathNode);
1358
+ return;
1359
+ }
1360
+
1361
+ // 0. Broad container collision: broad content containers cannot carry data-preview-static
1362
+ const tag = getJsxName(pathNode.node);
1363
+ const lower = String(tag || '').toLowerCase();
1364
+ if (BROAD_CONTENT_CONTAINERS.has(tag) || BROAD_CONTENT_CONTAINERS.has(lower)) {
1365
+ opening.attributes = attrs.filter(
1366
+ (a) => !(a.type === 'JSXAttribute' && a.name && a.name.name === 'data-preview-static')
1367
+ );
1368
+ cleaned++;
1369
+ this.traverse(pathNode);
1370
+ return;
1371
+ }
1372
+
1373
+ // 1. Direct collision: cannot share element with editable markers
1374
+ const hasDirectEditable = attrs.some(
1143
1375
  (a) => a.type === 'JSXAttribute' && a.name && (
1144
1376
  a.name.name === 'data-preview-field-path' ||
1145
1377
  a.name.name === 'data-preview-list-path' ||
1146
1378
  a.name.name === 'data-preview-item-path'
1147
1379
  )
1148
1380
  );
1149
- const hasStatic = attrs.some(
1150
- (a) => a.type === 'JSXAttribute' && a.name && a.name.name === 'data-preview-static'
1151
- );
1152
- if (hasEditable && hasStatic) {
1153
- pathNode.node.attributes = attrs.filter(
1381
+
1382
+ // 2. Hierarchical collision: static element cannot enclose editable descendants
1383
+ let hasNestedEditable = false;
1384
+ if (!hasDirectEditable) {
1385
+ recast.types.visit(pathNode.node, {
1386
+ visitJSXElement(inner) {
1387
+ if (inner.node === pathNode.node) {
1388
+ this.traverse(inner);
1389
+ return;
1390
+ }
1391
+ const innerAttrs = inner.node.openingElement?.attributes || [];
1392
+ if (innerAttrs.some(
1393
+ (a) => a.type === 'JSXAttribute' && a.name && (
1394
+ a.name.name === 'data-preview-field-path' ||
1395
+ a.name.name === 'data-preview-list-path' ||
1396
+ a.name.name === 'data-preview-item-path'
1397
+ )
1398
+ )) {
1399
+ hasNestedEditable = true;
1400
+ return false;
1401
+ }
1402
+ this.traverse(inner);
1403
+ },
1404
+ });
1405
+ }
1406
+
1407
+ if (hasDirectEditable || hasNestedEditable) {
1408
+ opening.attributes = attrs.filter(
1154
1409
  (a) => !(a.type === 'JSXAttribute' && a.name && a.name.name === 'data-preview-static')
1155
1410
  );
1156
1411
  cleaned++;
@@ -1162,7 +1417,7 @@ function sanitizeContradictoryMarkers(ast) {
1162
1417
  }
1163
1418
 
1164
1419
  function sanitizeContradictoryMarkersInSource(code, relativeFile) {
1165
- if (!code.includes('data-preview-static') && !code.includes('data-preview-field-path')) {
1420
+ if (!code.includes('data-preview-static') && !code.includes('data-preview-field-path') && !code.includes('overflow-hidden')) {
1166
1421
  return { code, updated: false };
1167
1422
  }
1168
1423
  let ast;
@@ -1175,8 +1430,9 @@ function sanitizeContradictoryMarkersInSource(code, relativeFile) {
1175
1430
  const healedBroad = healBroadContainerMarkers(ast);
1176
1431
  const healedEmpty = healEmptyStateConditionals(ast);
1177
1432
  const healedHidden = healHiddenPreviewMarkers(ast);
1178
- if (cleaned === 0 && healedBroad === 0 && healedEmpty === 0 && healedHidden === 0) return { code, updated: false };
1179
- return { code: printSource(ast, code), updated: true, count: cleaned + healedBroad + healedEmpty + healedHidden };
1433
+ const healedOverflow = healSectionOverflowHidden(ast);
1434
+ if (cleaned === 0 && healedBroad === 0 && healedEmpty === 0 && healedHidden === 0 && healedOverflow === 0) return { code, updated: false };
1435
+ return { code: printSource(ast, code), updated: true, count: cleaned + healedBroad + healedEmpty + healedHidden + healedOverflow };
1180
1436
  }
1181
1437
 
1182
1438
  function healBroadContainerMarkers(ast) {
@@ -1415,8 +1671,75 @@ function healLegacyProductDetailLinks(ast) {
1415
1671
  return healed;
1416
1672
  }
1417
1673
 
1674
+ function healSectionOverflowHidden(ast) {
1675
+ let healed = 0;
1676
+ recast.types.visit(ast, {
1677
+ visitJSXOpeningElement(pathNode) {
1678
+ const node = pathNode.node;
1679
+ const tag = getJsxName(node);
1680
+ const lower = String(tag || '').toLowerCase();
1681
+ const isContainer = lower === 'section' || lower === 'div' || lower === 'main' || lower === 'article';
1682
+ if (!isContainer) {
1683
+ this.traverse(pathNode);
1684
+ return;
1685
+ }
1686
+ const classAttr = (node.attributes || []).find(
1687
+ (a) => a.type === 'JSXAttribute' && a.name && (a.name.name === 'className' || a.name.name === 'class')
1688
+ );
1689
+ if (!classAttr || !classAttr.value) {
1690
+ this.traverse(pathNode);
1691
+ return;
1692
+ }
1693
+ let modified = false;
1694
+ if (classAttr.value.type === 'StringLiteral' || classAttr.value.type === 'Literal') {
1695
+ const val = String(classAttr.value.value || '');
1696
+ if (/\boverflow-hidden\b/.test(val)) {
1697
+ classAttr.value.value = val.replace(/\boverflow-hidden\b/g, 'overflow-clip');
1698
+ modified = true;
1699
+ }
1700
+ } else if (classAttr.value.type === 'JSXExpressionContainer') {
1701
+ const expr = classAttr.value.expression;
1702
+ if (expr && (expr.type === 'StringLiteral' || expr.type === 'Literal')) {
1703
+ const val = String(expr.value || '');
1704
+ if (/\boverflow-hidden\b/.test(val)) {
1705
+ expr.value = val.replace(/\boverflow-hidden\b/g, 'overflow-clip');
1706
+ modified = true;
1707
+ }
1708
+ } else if (expr && expr.type === 'TemplateLiteral') {
1709
+ for (const quasi of expr.quasis || []) {
1710
+ if (quasi.value && /\boverflow-hidden\b/.test(quasi.value.raw || '')) {
1711
+ quasi.value.raw = (quasi.value.raw || '').replace(/\boverflow-hidden\b/g, 'overflow-clip');
1712
+ if (quasi.value.cooked) {
1713
+ quasi.value.cooked = (quasi.value.cooked || '').replace(/\boverflow-hidden\b/g, 'overflow-clip');
1714
+ }
1715
+ modified = true;
1716
+ }
1717
+ }
1718
+ }
1719
+ }
1720
+ if (modified) healed++;
1721
+ this.traverse(pathNode);
1722
+ },
1723
+ });
1724
+ return healed;
1725
+ }
1726
+
1727
+ function healMissingSiteDataHooks(code, filePath = 'file.tsx') {
1728
+ if (!code.includes('siteData')) return code;
1729
+ try {
1730
+ const ast = parseSource(code, filePath);
1731
+ const injected = injectSiteDataHook(ast);
1732
+ if (injected) {
1733
+ ensureImport(ast, '@deneb-ui/ui', ['useSiteData']);
1734
+ return printSource(ast, code);
1735
+ }
1736
+ } catch {}
1737
+ return code;
1738
+ }
1739
+
1418
1740
  module.exports = {
1419
1741
  applyFilePlan,
1742
+ applyCollectionTransform,
1420
1743
  instrumentLayoutSource,
1421
1744
  instrumentPageKey,
1422
1745
  resolveSiteDataSpecifier,
@@ -1429,6 +1752,9 @@ module.exports = {
1429
1752
  healBroadContainerMarkers,
1430
1753
  healEmptyStateConditionals,
1431
1754
  healHiddenPreviewMarkers,
1755
+ healSectionOverflowHidden,
1432
1756
  healLegacyProductDetailLinks,
1757
+ healMissingSiteDataHooks,
1758
+ injectSiteDataHook,
1433
1759
  };
1434
1760
 
@@ -2178,7 +2178,7 @@ function auditSensitiveAttributes(
2178
2178
 
2179
2179
  function isHiddenHtmlElement(token: string, tag: string) {
2180
2180
  if (
2181
- /\bhidden(?:\s|=|\/?>)/i.test(token) ||
2181
+ /(?:^|\s)hidden(?:\s|=|\/?>)/i.test(token) ||
2182
2182
  /\baria-hidden\s*=\s*(?:"true"|'true')/i.test(token)
2183
2183
  ) {
2184
2184
  return true;
@@ -9,6 +9,7 @@
9
9
  "__fivoraIntake.referenceWebsiteUrl",
10
10
  "additionalPages[*].id",
11
11
  "additionalPages[*].route",
12
+ "additionalPages[*].label",
12
13
  "common.currency",
13
14
  "common.whatsappNumber",
14
15
  "common.whatsapp",