@deneb-ui/cli 2.0.47 → 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.47",
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.47",
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);
@@ -701,3 +702,176 @@ export function BrandMarquee() {
701
702
  assert.doesNotThrow(() => parseSource(result.code, 'BrandMarquee.tsx'));
702
703
  });
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,
@@ -71,14 +71,14 @@ function inferFieldName(kind, tag, text, extra = {}) {
71
71
  if (extra.action === 'email') return 'emailUrl';
72
72
  if (extra.cta) return extra.cta === 'primary' ? 'primaryCtaUrl' : `${extra.cta}Url`;
73
73
  const fromText = toCamel([text || '', 'url']);
74
- return fromText || 'ctaUrl';
74
+ return fromText || (extra.action ? `${extra.action}Url` : 'ctaUrl');
75
75
  }
76
76
  if (kind === 'label' && extra.paired) {
77
77
  if (extra.action === 'whatsapp') return 'whatsappLabel';
78
78
  if (extra.action === 'phone') return 'phoneLabel';
79
79
  if (extra.action === 'email') return 'emailLabel';
80
80
  if (extra.cta) return extra.cta === 'primary' ? 'primaryCtaLabel' : `${extra.cta}Label`;
81
- return toCamel([text || '', 'label']) || 'ctaLabel';
81
+ return toCamel([text || '', 'label']) || (extra.action ? `${extra.action}Label` : 'ctaLabel');
82
82
  }
83
83
  if (kind === 'image') return extra.alt ? toCamel([extra.alt, 'image']) || 'image' : 'image';
84
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'))