@deneb-ui/cli 2.0.46 → 2.0.48

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deneb-ui/cli",
3
- "version": "2.0.46",
3
+ "version": "2.0.48",
4
4
  "description": "Official DENEB CLI — scaffold, convert, validate, and package Fivora-ready storefront templates.",
5
5
  "bin": {
6
6
  "deneb": "bin/index.js",
@@ -49,7 +49,7 @@
49
49
  "license": "MIT",
50
50
  "dependencies": {
51
51
  "@babel/parser": "^7.28.0",
52
- "@deneb-ui/core": "^2.0.46",
52
+ "@deneb-ui/core": "^2.0.48",
53
53
  "@octokit/rest": "^22.0.1",
54
54
  "adm-zip": "^0.6.0",
55
55
  "dotenv": "^17.4.2",
@@ -15,6 +15,7 @@ const { enrichSchemasFromContent } = require('../manifest.cjs');
15
15
  const { runDenebArc } = require('../index.cjs');
16
16
  const { parseSource } = require('../ast.cjs');
17
17
  const { loadFingerprintBoost } = require('../learning.cjs');
18
+ const { classifyActionIntent } = require('../adapters.cjs');
18
19
 
19
20
  test('field-paths recognizes list action CTA keys', () => {
20
21
  assert.equal(isListActionCtaKey('preOrderCta'), true);
@@ -626,3 +627,251 @@ export { useSiteData, contentText };
626
627
  assert.match(out, /export\s*\{[^}]*useSiteData[^}]*\}\s*from\s*['"]@deneb-ui\/ui['"]/);
627
628
  assert.doesNotMatch(out, /import\s*\{[^}]*useSiteData/);
628
629
  });
630
+
631
+ test('toCamel and optionalMember safely handle strings starting with numbers without syntax errors', () => {
632
+ const { toCamel } = require('../field-paths.cjs');
633
+ const { optionalMember, parseSource } = require('../ast.cjs');
634
+ const recast = require('recast');
635
+
636
+ // toCamel prefixes identifiers starting with a number
637
+ assert.equal(toCamel(['1800840AURA']), 'item1800840aura');
638
+ assert.equal(toCamel(['25-Min', 'Express']), 'item25MinExpress');
639
+
640
+ // optionalMember safely falls back to computed string literal for non-identifier keys
641
+ const chain = optionalMember(['siteData', 'content', 'home', '100EncryptedLabel']);
642
+ const code = recast.print(chain).code;
643
+ assert.equal(code, 'siteData?.content?.home?.["100EncryptedLabel"]');
644
+
645
+ // Verify it parses as valid JS without syntax errors
646
+ assert.doesNotThrow(() => parseSource(`const x = ${code};`, 'test.tsx'));
647
+ });
648
+
649
+ test('bindArrayDeclaration transforms module-scope array into DEFAULT_ fallback in client components', () => {
650
+ const clientCode = `'use client';
651
+ import React from 'react';
652
+
653
+ const BRANDS = [
654
+ { name: 'Apple' },
655
+ { name: 'Samsung' },
656
+ ];
657
+
658
+ export function BrandMarquee() {
659
+ return (
660
+ <div>
661
+ {BRANDS.map((b) => (
662
+ <div key={b.name} className="brand-item">
663
+ <span>{b.name}</span>
664
+ </div>
665
+ ))}
666
+ </div>
667
+ );
668
+ }
669
+ `;
670
+
671
+ const profile = {
672
+ root: os.tmpdir(),
673
+ framework: 'nextjs',
674
+ router: 'next-app',
675
+ language: 'typescript',
676
+ cssSystems: ['tailwind'],
677
+ hasSrc: true,
678
+ aliasMap: { '@/*': ['src/*'] },
679
+ };
680
+
681
+ const analysis = analyzeFile({
682
+ code: clientCode,
683
+ relativeFile: 'src/components/BrandMarquee.tsx',
684
+ profile,
685
+ graph: { sharedFiles: [] },
686
+ ownerScope: 'home',
687
+ componentMeta: { name: 'BrandMarquee', role: 'about' },
688
+ });
689
+ analysis.code = clientCode;
690
+ analysis.relativeFile = 'src/components/BrandMarquee.tsx';
691
+
692
+ const plan = planTransformations({ profile, analyses: [analysis] });
693
+ const result = applyFilePlan(plan.files[0], profile);
694
+
695
+ assert.equal(result.changed, true);
696
+ // Module-scope array renamed to DEFAULT_BRANDS with literal array preserved
697
+ assert.match(result.code, /const DEFAULT_BRANDS = \[\s*\{\s*name:\s*['"]Apple['"]\s*\}/);
698
+ // Inside component body: useSiteData hook followed by dynamic BRANDS binding
699
+ assert.match(result.code, /const siteData = useSiteData\(\);/);
700
+ assert.match(result.code, /const BRANDS = siteData\?\.content\?\.home\?\.BRANDS \?\? DEFAULT_BRANDS;/);
701
+ // Verify AST parses cleanly
702
+ assert.doesNotThrow(() => parseSource(result.code, 'BrandMarquee.tsx'));
703
+ });
704
+
705
+ test('classifyActionIntent identifies action keywords and assigns correct fallback URLs', () => {
706
+ const wa = classifyActionIntent('Order on WhatsApp', null);
707
+ assert.equal(wa?.action, 'whatsapp');
708
+ assert.equal(wa?.defaultUrl, 'https://wa.me/1234567890');
709
+ assert.equal(wa?.external, true);
710
+
711
+ const call = classifyActionIntent('Call Us', null);
712
+ assert.equal(call?.action, 'phone');
713
+ assert.equal(call?.defaultUrl, 'tel:+1234567890');
714
+
715
+ const dir = classifyActionIntent('Get Directions', null);
716
+ assert.equal(dir?.action, 'directions');
717
+ assert.equal(dir?.defaultUrl, 'https://maps.google.com/?q=store+location');
718
+ assert.equal(dir?.external, true);
719
+
720
+ const loc = classifyActionIntent('Our Location', null);
721
+ assert.equal(loc?.action, 'location');
722
+ assert.equal(loc?.defaultUrl, 'https://maps.google.com/?q=store+location');
723
+ assert.equal(loc?.external, true);
724
+
725
+ const shop = classifyActionIntent('Shop Now', null);
726
+ assert.equal(shop?.action, 'shop');
727
+ assert.equal(shop?.defaultUrl, '/shop');
728
+ assert.equal(shop?.external, false);
729
+
730
+ const nonAction = classifyActionIntent('Submit Form', null);
731
+ assert.equal(nonAction, null);
732
+ });
733
+
734
+ test('semantic engine recognizes <button> with action text as split-action-contract', () => {
735
+ const code = `
736
+ export function ContactBar() {
737
+ return (
738
+ <div className="contact-bar">
739
+ <button className="btn-primary">Order on WhatsApp</button>
740
+ <button className="btn-secondary">Get Directions</button>
741
+ <button type="submit">Submit Feedback</button>
742
+ </div>
743
+ );
744
+ }
745
+ `;
746
+ const profile = {
747
+ root: os.tmpdir(),
748
+ framework: 'nextjs',
749
+ router: 'next-app',
750
+ language: 'typescript',
751
+ cssSystems: ['tailwind'],
752
+ hasSrc: true,
753
+ };
754
+
755
+ const analysis = analyzeFile({
756
+ code,
757
+ relativeFile: 'src/components/ContactBar.tsx',
758
+ profile,
759
+ graph: { sharedFiles: [] },
760
+ ownerScope: 'home',
761
+ componentMeta: { name: 'ContactBar', role: 'contact' },
762
+ });
763
+
764
+ const waSplit = analysis.candidates.find((c) => c.operation === 'split-action-contract' && c.label === 'Order on WhatsApp');
765
+ assert.ok(waSplit, 'expected split-action-contract for WhatsApp button');
766
+ assert.equal(waSplit.extra.action, 'whatsapp');
767
+ assert.equal(waSplit.value, 'https://wa.me/1234567890');
768
+
769
+ const dirSplit = analysis.candidates.find((c) => c.operation === 'split-action-contract' && c.label === 'Get Directions');
770
+ assert.ok(dirSplit, 'expected split-action-contract for Directions button');
771
+ assert.equal(dirSplit.extra.action, 'directions');
772
+
773
+ // Submit button should NOT be split into a redirect link
774
+ const submitSplit = analysis.candidates.find((c) => c.label === 'Submit Feedback' && c.operation === 'split-action-contract');
775
+ assert.equal(submitSplit, undefined, 'submit button must not be an action redirect');
776
+ });
777
+
778
+ test('AST transformer converts <button> action to <a> with redirect URL and editable text label', () => {
779
+ const code = `
780
+ export function ActionPanel() {
781
+ return (
782
+ <div className="panel">
783
+ <button className="px-4 py-2 bg-green-600 text-white rounded">Order on WhatsApp</button>
784
+ </div>
785
+ );
786
+ }
787
+ `;
788
+ const profile = {
789
+ root: os.tmpdir(),
790
+ framework: 'nextjs',
791
+ router: 'next-app',
792
+ language: 'typescript',
793
+ cssSystems: ['tailwind'],
794
+ hasSrc: true,
795
+ aliasMap: { '@/*': ['src/*'] },
796
+ };
797
+
798
+ const analysis = analyzeFile({
799
+ code,
800
+ relativeFile: 'src/components/ActionPanel.tsx',
801
+ profile,
802
+ graph: { sharedFiles: [] },
803
+ ownerScope: 'home',
804
+ componentMeta: { name: 'ActionPanel', role: 'hero' },
805
+ });
806
+ analysis.code = code;
807
+ analysis.relativeFile = 'src/components/ActionPanel.tsx';
808
+
809
+ const plan = planTransformations({ profile, analyses: [analysis] });
810
+ const result = applyFilePlan(plan.files[0], profile);
811
+
812
+ assert.equal(result.changed, true);
813
+ // Converted from <button> to <a ...>
814
+ assert.match(result.code, /<a\s+[^>]*href=\{siteData\?\.content\?\.home\?\.hero\?\.whatsappUrl \?\? "https:\/\/wa\.me\/1234567890"\}/);
815
+ assert.match(result.code, /target="_blank"/);
816
+ assert.match(result.code, /rel="noopener noreferrer"/);
817
+ assert.match(result.code, /data-preview-static="action-link"/);
818
+ // Text label wrapped in editable span
819
+ assert.match(result.code, /<span\s+data-preview-field-path="home\.hero\.whatsappLabel"[^>]*>\{siteData\?\.content\?\.home\?\.hero\?\.whatsappLabel \?\? "Order on WhatsApp"\}<\/span>/);
820
+ // Hidden URL span present for Fivora contract
821
+ assert.match(result.code, /<span hidden aria-hidden="true" data-preview-field-path="home\.hero\.whatsappUrl">/);
822
+ // No parse errors
823
+ assert.doesNotThrow(() => parseSource(result.code, 'ActionPanel.tsx'));
824
+ });
825
+
826
+ test('end-to-end ARC conversion transforms action buttons and passes strict Fivora audit', () => {
827
+ const dir = copyOf(FIXTURE);
828
+ const actionPagePath = path.join(dir, 'src', 'components', 'ActionPage.tsx');
829
+ fs.writeFileSync(
830
+ actionPagePath,
831
+ `
832
+ export function ActionPage() {
833
+ return (
834
+ <div className="action-page p-8">
835
+ <h1>Connect & Visit</h1>
836
+ <button className="btn-wa">Order on WhatsApp</button>
837
+ <button className="btn-call">Call Us</button>
838
+ <button className="btn-dir">Get Directions</button>
839
+ <button className="btn-loc">Our Location</button>
840
+ <button className="btn-shop">Shop Now</button>
841
+ </div>
842
+ );
843
+ }
844
+ `
845
+ );
846
+
847
+ // Link ActionPage in page.tsx
848
+ const pageFile = path.join(dir, 'src', 'app', 'page.tsx');
849
+ const pageSrc = fs.readFileSync(pageFile, 'utf8');
850
+ fs.writeFileSync(
851
+ pageFile,
852
+ `import { ActionPage } from '../components/ActionPage';\n` +
853
+ pageSrc.replace('</main>', ' <ActionPage />\n </main>')
854
+ );
855
+
856
+ silence(() => runDenebArc(dir, 'actions-fixture', { telemetry: 'off' }));
857
+
858
+ const transformed = fs.readFileSync(actionPagePath, 'utf8');
859
+ // All 5 buttons converted to <a> tags with href
860
+ assert.match(transformed, /<a\s+[^>]*href=\{siteData\?\.content\?\.home\?\.contact\?\.whatsappUrl/);
861
+ assert.match(transformed, /<a\s+[^>]*href=\{siteData\?\.content\?\.home\?\.contact\?\.phoneUrl/);
862
+ assert.match(transformed, /<a\s+[^>]*href=\{siteData\?\.content\?\.home\?\.contact\?\.getDirectionsUrl/);
863
+ assert.match(transformed, /<a\s+[^>]*href=\{siteData\?\.content\?\.home\?\.contact\?\.ourLocationUrl/);
864
+ assert.match(transformed, /<a\s+[^>]*href=\{siteData\?\.content\?\.home\?\.shop\?\.shopNowUrl/);
865
+
866
+ // All 5 buttons have editable labels
867
+ assert.match(transformed, /data-preview-field-path="home\.contact\.whatsappLabel"/);
868
+ assert.match(transformed, /data-preview-field-path="home\.contact\.phoneLabel"/);
869
+ assert.match(transformed, /data-preview-field-path="home\.contact\.getDirectionsLabel"/);
870
+ assert.match(transformed, /data-preview-field-path="home\.contact\.ourLocationLabel"/);
871
+ assert.match(transformed, /data-preview-field-path="home\.shop\.shopNowLabel"/);
872
+
873
+ // Strict Fivora audit passes with 0 errors
874
+ const { errors } = auditFivora(dir);
875
+ assert.deepEqual(errors, []);
876
+ });
877
+
@@ -188,6 +188,79 @@ function isLikelyCtaClass(className) {
188
188
  return /\b(btn|button|cta|action|rounded|bg-|hero[-_]?cta)\b/i.test(className || '');
189
189
  }
190
190
 
191
+ function classifyActionIntent(text, href) {
192
+ const t = String(text || '').trim().toLowerCase();
193
+ const h = String(href || '').trim().toLowerCase();
194
+
195
+ // 1. WhatsApp
196
+ if (/wa\.me|whatsapp/i.test(h) || /\b(?:whatsapp|wa\.me)\b/i.test(t) || /order on whatsapp|chat on whatsapp|message on whatsapp/i.test(t)) {
197
+ return {
198
+ action: 'whatsapp',
199
+ defaultUrl: /^https?:\/\//i.test(h) && !h.includes('#') ? href : 'https://wa.me/1234567890',
200
+ external: true,
201
+ };
202
+ }
203
+
204
+ // 2. Phone / Call
205
+ if (/^tel:/i.test(h) || /\b(?:call\s*(?:me|us|now)?|phone\s*(?:me|us|now)?|direct\s*line|ring\s*us)\b/i.test(t)) {
206
+ return {
207
+ action: 'phone',
208
+ defaultUrl: /^tel:/i.test(h) ? href : 'tel:+1234567890',
209
+ external: false,
210
+ };
211
+ }
212
+
213
+ // 3. Directions
214
+ if (/maps\.google|goo\.gl\/maps|map\.apple/i.test(h) || /\b(?:directions?|get\s*directions?|route|navigate)\b/i.test(t)) {
215
+ return {
216
+ action: 'directions',
217
+ defaultUrl: /^https?:\/\//i.test(h) && !h.includes('#') ? href : 'https://maps.google.com/?q=store+location',
218
+ external: true,
219
+ };
220
+ }
221
+
222
+ // 4. Location / Map
223
+ if (/\b(?:locations?|our\s*location|store\s*location|view\s*location|find\s*us|visit\s*us|locate\s*us|map)\b/i.test(t)) {
224
+ return {
225
+ action: 'location',
226
+ defaultUrl: /^https?:\/\//i.test(h) && !h.includes('#') ? href : 'https://maps.google.com/?q=store+location',
227
+ external: true,
228
+ };
229
+ }
230
+
231
+ // 5. Shop / Order / Catalog / Menu
232
+ if (/\b(?:shop(?:\s*now)?|order(?:\s*now)?|buy(?:\s*now)?|explore\s*(?:shop|products|offerings|collection|menu)|browse\s*(?:menu|catalog|shop)|view\s*(?:menu|catalog)|menu)\b/i.test(t) || /^\/(?:shop|products|menu|catalog)/i.test(h)) {
233
+ return {
234
+ action: 'shop',
235
+ defaultUrl: h && !['#', ''].includes(h) ? href : '/shop',
236
+ external: false,
237
+ };
238
+ }
239
+
240
+ // 6. Email
241
+ if (/^mailto:/i.test(h) || /\b(?:email\s*us|send\s*(?:us\s*)?email|contact\s*by\s*email)\b/i.test(t)) {
242
+ return {
243
+ action: 'email',
244
+ defaultUrl: /^mailto:/i.test(h) ? href : 'mailto:info@example.com',
245
+ external: false,
246
+ };
247
+ }
248
+
249
+ // 7. Check href using existing classifyHref
250
+ if (h && !['#', ''].includes(h)) {
251
+ const fromHref = classifyHref(href);
252
+ if (fromHref && fromHref !== 'link') {
253
+ return {
254
+ action: fromHref,
255
+ defaultUrl: href,
256
+ external: ['instagram', 'facebook', 'tiktok', 'twitter', 'youtube', 'linkedin', 'pinterest'].includes(fromHref),
257
+ };
258
+ }
259
+ }
260
+
261
+ return null;
262
+ }
263
+
191
264
  module.exports = {
192
265
  ADAPTERS,
193
266
  activeAdapters,
@@ -195,6 +268,7 @@ module.exports = {
195
268
  resolveActionWithAdapters,
196
269
  isIconComponent,
197
270
  classifyHref,
271
+ classifyActionIntent,
198
272
  isLikelyCtaClass,
199
273
  DECORATIVE_TAGS,
200
274
  SKIP_TAGS,
package/src/arc/ast.cjs CHANGED
@@ -158,10 +158,17 @@ function isJsxTextHeavy(node) {
158
158
  return meaningful.length > 0;
159
159
  }
160
160
 
161
+ const VALID_IDENTIFIER_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
162
+
161
163
  function optionalMember(parts) {
162
164
  let expr = b.identifier(parts[0]);
163
165
  for (let i = 1; i < parts.length; i++) {
164
- expr = b.optionalMemberExpression(expr, b.identifier(parts[i]), false, true);
166
+ const part = String(parts[i]);
167
+ if (VALID_IDENTIFIER_REGEX.test(part)) {
168
+ expr = b.optionalMemberExpression(expr, b.identifier(part), false, true);
169
+ } else {
170
+ expr = b.optionalMemberExpression(expr, b.stringLiteral(part), true, true);
171
+ }
165
172
  }
166
173
  return expr;
167
174
  }
@@ -15,13 +15,17 @@ function toCamel(parts) {
15
15
  .filter((w, i) => i === 0 || !STOP_WORDS.has(w.toLowerCase()))
16
16
  .slice(0, 5);
17
17
  if (!cleaned.length) return '';
18
- return cleaned
18
+ const camel = cleaned
19
19
  .map((word, i) => {
20
20
  const lower = word.toLowerCase();
21
21
  if (i === 0) return lower;
22
22
  return lower.charAt(0).toUpperCase() + lower.slice(1);
23
23
  })
24
24
  .join('');
25
+ if (/^[0-9]/.test(camel)) {
26
+ return 'item' + camel.charAt(0).toUpperCase() + camel.slice(1);
27
+ }
28
+ return camel;
25
29
  }
26
30
 
27
31
  function inferSection(context) {
@@ -67,14 +71,14 @@ function inferFieldName(kind, tag, text, extra = {}) {
67
71
  if (extra.action === 'email') return 'emailUrl';
68
72
  if (extra.cta) return extra.cta === 'primary' ? 'primaryCtaUrl' : `${extra.cta}Url`;
69
73
  const fromText = toCamel([text || '', 'url']);
70
- return fromText || 'ctaUrl';
74
+ return fromText || (extra.action ? `${extra.action}Url` : 'ctaUrl');
71
75
  }
72
76
  if (kind === 'label' && extra.paired) {
73
77
  if (extra.action === 'whatsapp') return 'whatsappLabel';
74
78
  if (extra.action === 'phone') return 'phoneLabel';
75
79
  if (extra.action === 'email') return 'emailLabel';
76
80
  if (extra.cta) return extra.cta === 'primary' ? 'primaryCtaLabel' : `${extra.cta}Label`;
77
- return toCamel([text || '', 'label']) || 'ctaLabel';
81
+ return toCamel([text || '', 'label']) || (extra.action ? `${extra.action}Label` : 'ctaLabel');
78
82
  }
79
83
  if (kind === 'image') return extra.alt ? toCamel([extra.alt, 'image']) || 'image' : 'image';
80
84
  if (kind === 'alt') return extra.imageField ? extra.imageField.replace(/Image$/, 'ImageAlt').replace(/image$/, 'imageAlt') : 'imageAlt';
@@ -230,9 +230,12 @@ function sectionForAction(scope, section, extra) {
230
230
  if (scope === 'common' && (extra.social || ['instagram', 'facebook', 'twitter', 'tiktok', 'youtube', 'linkedin'].includes(extra.action))) {
231
231
  return 'footer';
232
232
  }
233
- if (extra.action === 'whatsapp' || extra.action === 'phone' || extra.action === 'email') {
233
+ if (['whatsapp', 'phone', 'email', 'directions', 'location'].includes(extra.action)) {
234
234
  return section || 'contact';
235
235
  }
236
+ if (extra.action === 'shop') {
237
+ return section || 'shop';
238
+ }
236
239
  return section;
237
240
  }
238
241
 
@@ -16,6 +16,7 @@ const {
16
16
  resolveActionWithAdapters,
17
17
  isIconComponent,
18
18
  classifyHref,
19
+ classifyActionIntent,
19
20
  isLikelyCtaClass,
20
21
  SKIP_TAGS,
21
22
  HEADING_TAGS,
@@ -474,22 +475,30 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
474
475
  }
475
476
 
476
477
  const actionableHref = href || (name === 'Button' ? getJsxAttributeLiteral(node, 'href') : null);
477
- const isAction = Boolean(actionableHref) && ACTION_TAGS.has(name);
478
- if (isAction && actionableHref) {
479
- const action = classifyHref(actionableHref);
480
- const innerText = textInfo.dynamic ? '' : textInfo.text;
481
- const looksCta = isLikelyCtaClass(className) || ['whatsapp', 'phone', 'email'].includes(action) || Boolean(innerText);
478
+ const innerText = textInfo.dynamic ? '' : textInfo.text;
479
+ const typeAttr = getJsxAttributeLiteral(node, 'type');
480
+ const isSubmit = typeAttr === 'submit';
481
+
482
+ // Check for action intent via classifyActionIntent (covers WhatsApp, Call/Phone, Directions, Location, Shop, Email)
483
+ const actionIntent = !isSubmit && !apiOwned ? classifyActionIntent(innerText, actionableHref) : null;
484
+ const isAction = (Boolean(actionableHref) || Boolean(actionIntent)) && (ACTION_TAGS.has(name) || isLikelyCtaClass(className));
485
+
486
+ if (isAction && (actionableHref || actionIntent)) {
487
+ const action = actionIntent ? actionIntent.action : classifyHref(actionableHref);
488
+ const resolvedHref = actionableHref || (actionIntent ? actionIntent.defaultUrl : '#');
489
+ const looksCta = isLikelyCtaClass(className) || ['whatsapp', 'phone', 'email', 'directions', 'location', 'shop'].includes(action) || Boolean(innerText);
482
490
  if (looksCta && innerText && !textInfo.dynamic && !apiOwned) {
483
491
  usedLocs.add(loc);
484
492
  candidates.push({
485
493
  ...baseMeta,
486
494
  kind: 'split-action-contract',
487
495
  operation: 'split-action-contract',
488
- value: actionableHref,
496
+ value: resolvedHref,
489
497
  label: innerText,
490
498
  extra: {
491
499
  action,
492
- social: !['whatsapp', 'phone', 'email', 'link'].includes(action),
500
+ external: actionIntent ? actionIntent.external : false,
501
+ social: !['whatsapp', 'phone', 'email', 'directions', 'location', 'shop', 'link'].includes(action),
493
502
  platform: ['instagram', 'facebook', 'tiktok', 'twitter', 'youtube', 'linkedin', 'pinterest'].includes(action) ? action : undefined,
494
503
  },
495
504
  confidence: confidenceFor('split-action-contract', { action, social: action !== 'link' }),
@@ -500,7 +509,7 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
500
509
  return;
501
510
  }
502
511
 
503
- if (action !== 'link' && !innerText) {
512
+ if (action !== 'link' && !innerText && actionableHref) {
504
513
  usedLocs.add(loc);
505
514
  candidates.push({
506
515
  ...baseMeta,
@@ -142,14 +142,48 @@ function wrapHiddenUrlSibling(pathNode, urlField, fallback) {
142
142
  [b.jsxExpressionContainer(siteDataBinding(urlParts, fallback, 'url'))],
143
143
  false
144
144
  );
145
- pathNode.insertAfter(hiddenUrl);
145
+ if (pathNode.parent && Array.isArray(pathNode.parent.node?.children)) {
146
+ pathNode.insertAfter(hiddenUrl);
147
+ } else if (pathNode.node && Array.isArray(pathNode.node.children)) {
148
+ pathNode.node.children = [hiddenUrl, ...(pathNode.node.children || [])];
149
+ }
146
150
  }
147
151
 
148
152
  function applyTransformToElement(pathNode, transform) {
149
153
  const node = pathNode.node;
150
154
  if (transform.operation === 'split-action-contract') {
155
+ const tagName = getJsxName(node);
156
+ if (tagName === 'button') {
157
+ node.openingElement.name = b.jsxIdentifier('a');
158
+ if (node.closingElement) {
159
+ node.closingElement.name = b.jsxIdentifier('a');
160
+ }
161
+ node.openingElement.attributes = (node.openingElement.attributes || []).filter(
162
+ (attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'type')
163
+ );
164
+ }
165
+
151
166
  const urlParts = transform.urlField.split('.');
152
167
  replaceAttrValue(node, 'href', siteDataBinding(urlParts, transform.fallback, 'url'));
168
+
169
+ const isExternal =
170
+ transform.extra?.external ||
171
+ ['whatsapp', 'directions', 'location'].includes(transform.extra?.action) ||
172
+ /^https?:\/\//i.test(String(transform.fallback || ''));
173
+
174
+ if (isExternal && (tagName === 'button' || tagName === 'a')) {
175
+ if (!hasJsxAttribute(node, 'target')) {
176
+ node.openingElement.attributes.push(
177
+ b.jsxAttribute(b.jsxIdentifier('target'), b.stringLiteral('_blank'))
178
+ );
179
+ }
180
+ if (!hasJsxAttribute(node, 'rel')) {
181
+ node.openingElement.attributes.push(
182
+ b.jsxAttribute(b.jsxIdentifier('rel'), b.stringLiteral('noopener noreferrer'))
183
+ );
184
+ }
185
+ }
186
+
153
187
  if (!hasJsxAttribute(node, 'data-preview-static')) {
154
188
  node.openingElement.attributes.push(
155
189
  b.jsxAttribute(b.jsxIdentifier('data-preview-static'), b.stringLiteral('action-link'))
@@ -238,7 +272,7 @@ function wrapLiteralTextChildren(node, fieldPath, fallback) {
238
272
  * emitted as JSX template literals (`items[${index}].title`) so added and
239
273
  * reordered items stay editable, which is what strict mode requires.
240
274
  */
241
- function applyCollectionTransform(ast, transform) {
275
+ function applyCollectionTransform(ast, transform, isClient = false) {
242
276
  const listPath = transform.listField;
243
277
  const binding = transform.itemParam;
244
278
  if (!listPath || !binding) return false;
@@ -290,7 +324,7 @@ function applyCollectionTransform(ast, transform) {
290
324
  );
291
325
  }
292
326
 
293
- return bindArrayDeclaration(ast, mapCall, listPath);
327
+ return bindArrayDeclaration(ast, mapCall, listPath, isClient);
294
328
  }
295
329
 
296
330
  function findMapCall(ast, loc) {
@@ -376,7 +410,40 @@ function findListContainer(mapCallPath) {
376
410
  return null;
377
411
  }
378
412
 
379
- function bindArrayDeclaration(ast, mapCallPath, listPath) {
413
+ function isModuleLevel(pathNode) {
414
+ let current = pathNode.parentPath || pathNode.parent;
415
+ while (current) {
416
+ const type = current.node?.type;
417
+ if (
418
+ type === 'FunctionDeclaration' ||
419
+ type === 'FunctionExpression' ||
420
+ type === 'ArrowFunctionExpression'
421
+ ) {
422
+ return false;
423
+ }
424
+ if (type === 'Program') return true;
425
+ current = current.parentPath || current.parent;
426
+ }
427
+ return true;
428
+ }
429
+
430
+ function findEnclosingFunction(pathNode) {
431
+ let current = pathNode.parentPath || pathNode.parent;
432
+ while (current) {
433
+ const type = current.node?.type;
434
+ if (
435
+ type === 'FunctionDeclaration' ||
436
+ type === 'FunctionExpression' ||
437
+ type === 'ArrowFunctionExpression'
438
+ ) {
439
+ return current;
440
+ }
441
+ current = current.parentPath || current.parent;
442
+ }
443
+ return null;
444
+ }
445
+
446
+ function bindArrayDeclaration(ast, mapCallPath, listPath, isClient = false) {
380
447
  const arrayName = mapCallPath.node.callee.object?.name;
381
448
  if (!arrayName) return false;
382
449
  let bound = false;
@@ -392,6 +459,33 @@ function bindArrayDeclaration(ast, mapCallPath, listPath) {
392
459
  this.traverse(pathNode);
393
460
  return;
394
461
  }
462
+
463
+ if (isClient && isModuleLevel(pathNode)) {
464
+ const defaultName = 'DEFAULT_' + arrayName;
465
+ node.id.name = defaultName;
466
+ const fnPath = findEnclosingFunction(mapCallPath);
467
+ if (fnPath && fnPath.node.body?.type === 'BlockStatement') {
468
+ const body = fnPath.node.body.body;
469
+ const already = body.some((stmt) => recast.print(stmt).code.includes(`const ${arrayName} =`));
470
+ if (!already) {
471
+ const localDecl = b.variableDeclaration('const', [
472
+ b.variableDeclarator(
473
+ b.identifier(arrayName),
474
+ siteDataListBinding(listPath.split('.'), b.identifier(defaultName))
475
+ ),
476
+ ]);
477
+ const hookIdx = body.findIndex((stmt) => recast.print(stmt).code.includes('useSiteData'));
478
+ if (hookIdx >= 0) {
479
+ body.splice(hookIdx + 1, 0, localDecl);
480
+ } else {
481
+ body.unshift(localDecl);
482
+ }
483
+ }
484
+ }
485
+ bound = true;
486
+ return false;
487
+ }
488
+
395
489
  node.init = siteDataListBinding(listPath.split('.'), node.init);
396
490
  bound = true;
397
491
  return false;
@@ -549,7 +643,7 @@ function applyFilePlan(filePlan, profile) {
549
643
 
550
644
  if (transform.operation === 'collection-conversion') {
551
645
  try {
552
- if (applyCollectionTransform(ast, transform)) applied++;
646
+ if (applyCollectionTransform(ast, transform, isClient)) applied++;
553
647
  else failures.push({ loc: transform.loc, reason: 'collection-not-bindable' });
554
648
  } catch (err) {
555
649
  failures.push({ loc: transform.loc, reason: err.message });