@deneb-ui/cli 2.0.50 → 2.0.51

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.50",
3
+ "version": "2.0.51",
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.50",
52
+ "@deneb-ui/core": "^2.0.51",
53
53
  "@octokit/rest": "^22.0.1",
54
54
  "adm-zip": "^0.6.0",
55
55
  "dotenv": "^17.4.2",
@@ -729,7 +729,12 @@ test('classifyActionIntent identifies action keywords and assigns correct fallba
729
729
  assert.equal(shop?.defaultUrl, '/shop');
730
730
  assert.equal(shop?.external, false);
731
731
 
732
- const nonAction = classifyActionIntent('Submit Form', null);
732
+ const formSubmit = classifyActionIntent('Submit Form', null);
733
+ assert.equal(formSubmit?.action, 'form-submit');
734
+ assert.equal(formSubmit?.defaultUrl, 'https://wa.me/1234567890');
735
+ assert.equal(formSubmit?.external, true);
736
+
737
+ const nonAction = classifyActionIntent('Random Content Text', null);
733
738
  assert.equal(nonAction, null);
734
739
  });
735
740
 
@@ -1215,3 +1220,122 @@ export function CartDrawer() {
1215
1220
  assert.match(splitAction.urlField, /whatsapp/i);
1216
1221
  });
1217
1222
 
1223
+ test('classifyActionIntent recognizes form submit action keywords', () => {
1224
+ const submit = classifyActionIntent('Submit', null);
1225
+ assert.equal(submit?.action, 'form-submit');
1226
+ assert.equal(submit?.defaultUrl, 'https://wa.me/1234567890');
1227
+
1228
+ const sendMessage = classifyActionIntent('Send Message', null);
1229
+ assert.equal(sendMessage?.action, 'form-submit');
1230
+
1231
+ const confirmBooking = classifyActionIntent('Confirm Booking', null);
1232
+ assert.equal(confirmBooking?.action, 'form-submit');
1233
+
1234
+ const getQuote = classifyActionIntent('Get Quote', null);
1235
+ assert.equal(getQuote?.action, 'form-submit');
1236
+ });
1237
+
1238
+ test('semantic engine recognizes form inputs, labels, placeholders, and form-submit-action buttons', () => {
1239
+ const code = `
1240
+ export function BookingForm() {
1241
+ return (
1242
+ <form className="booking-form">
1243
+ <h2>Book Appointment</h2>
1244
+ <label>Full Name</label>
1245
+ <input type="text" placeholder="Enter your full name" />
1246
+ <label>Email Address</label>
1247
+ <input type="email" placeholder="you@example.com" />
1248
+ <label>Special Instructions</label>
1249
+ <textarea placeholder="Describe your request..." />
1250
+ <button type="submit">Confirm Booking</button>
1251
+ </form>
1252
+ );
1253
+ }
1254
+ `;
1255
+ const profile = {
1256
+ root: os.tmpdir(),
1257
+ framework: 'nextjs',
1258
+ router: 'next-app',
1259
+ language: 'typescript',
1260
+ cssSystems: ['tailwind'],
1261
+ hasSrc: true,
1262
+ };
1263
+
1264
+ const analysis = analyzeFile({
1265
+ code,
1266
+ relativeFile: 'src/components/BookingForm.tsx',
1267
+ profile,
1268
+ graph: { sharedFiles: [] },
1269
+ ownerScope: 'home',
1270
+ componentMeta: { name: 'BookingForm', role: 'form' },
1271
+ });
1272
+
1273
+ const placeholders = analysis.candidates.filter((c) => c.kind === 'placeholder');
1274
+ assert.equal(placeholders.length, 3, 'expected 3 placeholder candidates');
1275
+
1276
+ const labels = analysis.candidates.filter((c) => c.kind === 'text' && c.tag === 'label');
1277
+ assert.equal(labels.length, 3, 'expected 3 label candidates');
1278
+
1279
+ const submitAction = analysis.candidates.find((c) => c.kind === 'form-submit-action');
1280
+ assert.ok(submitAction, 'expected form-submit-action candidate');
1281
+ assert.equal(submitAction.label, 'Confirm Booking');
1282
+ assert.equal(submitAction.extra.action, 'form-submit');
1283
+ assert.equal(submitAction.extra.insideForm, true);
1284
+ });
1285
+
1286
+ test('planner and transformer make entire form editable with submit button and WhatsApp URL binding', () => {
1287
+ const code = `
1288
+ import React from 'react';
1289
+
1290
+ export function ContactSection() {
1291
+ return (
1292
+ <form className="contact-form">
1293
+ <label>Your Name</label>
1294
+ <input type="text" placeholder="John Doe" />
1295
+ <button type="submit">Send Message</button>
1296
+ </form>
1297
+ );
1298
+ }
1299
+ `;
1300
+ const profile = {
1301
+ root: os.tmpdir(),
1302
+ framework: 'nextjs',
1303
+ router: 'next-app',
1304
+ language: 'typescript',
1305
+ cssSystems: ['tailwind'],
1306
+ hasSrc: true,
1307
+ aliasMap: { '@/*': ['src/*'] },
1308
+ };
1309
+
1310
+ const analysis = analyzeFile({
1311
+ code,
1312
+ relativeFile: 'src/components/ContactSection.tsx',
1313
+ profile,
1314
+ graph: { sharedFiles: [] },
1315
+ ownerScope: 'home',
1316
+ componentMeta: { name: 'ContactSection', role: 'contact' },
1317
+ });
1318
+ analysis.code = code;
1319
+ analysis.relativeFile = 'src/components/ContactSection.tsx';
1320
+
1321
+ const plan = planTransformations({
1322
+ profile,
1323
+ analyses: [analysis],
1324
+ });
1325
+
1326
+ const filePlan = plan.files[0];
1327
+ const formSubmit = filePlan.transformations.find((t) => t.operation === 'form-submit-action');
1328
+ assert.ok(formSubmit, 'form-submit-action must be planned');
1329
+ assert.match(formSubmit.urlField, /formWhatsappUrl/i);
1330
+ assert.match(formSubmit.labelField, /formSubmitLabel/i);
1331
+
1332
+ const transformed = applyFilePlan(filePlan, profile);
1333
+ assert.ok(transformed.changed, 'file must be changed');
1334
+ assert.match(transformed.code, /data-preview-field-path/);
1335
+ assert.match(transformed.code, /formSubmitLabel/);
1336
+ assert.match(transformed.code, /formWhatsappUrl/);
1337
+ assert.match(transformed.code, /type="submit"/);
1338
+ assert.match(transformed.code, /hidden/);
1339
+ });
1340
+
1341
+
@@ -23,6 +23,8 @@ const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'Heading', 'Ti
23
23
  const TEXT_TAGS = new Set(['p', 'span', 'li', 'blockquote', 'figcaption', 'label', 'CardDescription', 'Description', 'Subtitle', 'Typography', 'Text', 'Badge', 'badge']);
24
24
  const ACTION_TAGS = new Set(['a', 'Link', 'NavLink', 'Button', 'button', 'IconButton', 'NavbarBrand']);
25
25
  const IMAGE_TAGS = new Set(['img', 'Image', 'Img', 'BackgroundImage']);
26
+ const FORM_INPUT_TAGS = new Set(['input', 'Input', 'textarea', 'Textarea', 'select', 'Select']);
27
+ const FORM_CONTAINER_TAGS = new Set(['form', 'Form']);
26
28
 
27
29
  const shadcn = createAdapter('shadcn', {
28
30
  detect: (project) => detectFromProfile(project, 'shadcn') || project.shadcn,
@@ -106,6 +108,9 @@ const deneb = createAdapter('deneb', {
106
108
  Boolean(project.dependencies && project.dependencies['@deneb-ui/ui']),
107
109
  recognizeNode(node) {
108
110
  const name = getJsxName(node);
111
+ if (['EditableContactForm', 'ContactForm'].includes(name)) {
112
+ return { library: 'deneb', kind: 'form', tag: name };
113
+ }
109
114
  if (['EditableGoogleFeedback', 'EditableCustomerReviews', 'CustomerReviews', 'GoogleFeedback'].includes(name)) {
110
115
  return { library: 'deneb', kind: 'feedback', tag: name };
111
116
  }
@@ -192,6 +197,18 @@ function classifyActionIntent(text, href) {
192
197
  const t = String(text || '').trim().toLowerCase();
193
198
  const h = String(href || '').trim().toLowerCase();
194
199
 
200
+ // 0. Form Submit / WhatsApp Form Action
201
+ if (
202
+ /^(submit|send\s*message|send\s*inquiry|confirm\s*booking|place\s*order|book\s*now|confirm|get\s*quote|request\s*quote|send\s*request|schedule|register|sign\s*up|subscribe|apply|enroll)$/i.test(t) ||
203
+ /\b(?:send\s*message|confirm\s*booking|submit\s*form|submit\s*inquiry|send\s*inquiry|request\s*quote|get\s*quote|send\s*request)\b/i.test(t)
204
+ ) {
205
+ return {
206
+ action: 'form-submit',
207
+ defaultUrl: /^https?:\/\//i.test(h) && !h.includes('#') ? href : 'https://wa.me/1234567890',
208
+ external: true,
209
+ };
210
+ }
211
+
195
212
  // 1. WhatsApp
196
213
  if (
197
214
  /wa\.me|whatsapp/i.test(h) ||
@@ -280,6 +297,8 @@ module.exports = {
280
297
  TEXT_TAGS,
281
298
  ACTION_TAGS,
282
299
  IMAGE_TAGS,
300
+ FORM_INPUT_TAGS,
301
+ FORM_CONTAINER_TAGS,
283
302
  collectJsxText,
284
303
  getJsxAttributeLiteral,
285
304
  };
@@ -47,6 +47,7 @@ function inferSection(context) {
47
47
  ['testimonials', /testimonial/],
48
48
  ['map', /map\b|location-map|google-map/],
49
49
  ['faq', /faq|accordion/],
50
+ ['form', /form|booking|inquiry|registration/],
50
51
  ['contact', /contact|whatsapp|mailto/],
51
52
  ['featuredProducts', /featured|product-grid|collection/],
52
53
  ['newsletter', /newsletter|subscribe/],
@@ -65,6 +66,7 @@ function inferSection(context) {
65
66
 
66
67
  function inferFieldName(kind, tag, text, extra = {}) {
67
68
  if (kind === 'url') {
69
+ if (extra.action === 'form-submit') return 'formWhatsappUrl';
68
70
  if (extra.platform) return `${extra.platform}Url`;
69
71
  if (extra.action === 'whatsapp') return 'whatsappUrl';
70
72
  if (extra.action === 'phone') return 'phoneUrl';
@@ -74,6 +76,7 @@ function inferFieldName(kind, tag, text, extra = {}) {
74
76
  return fromText || (extra.action ? `${extra.action}Url` : 'ctaUrl');
75
77
  }
76
78
  if (kind === 'label' && extra.paired) {
79
+ if (extra.action === 'form-submit') return 'formSubmitLabel';
77
80
  if (extra.action === 'whatsapp') return 'whatsappLabel';
78
81
  if (extra.action === 'phone') return 'phoneLabel';
79
82
  if (extra.action === 'email') return 'emailLabel';
@@ -100,6 +103,7 @@ function inferFieldName(kind, tag, text, extra = {}) {
100
103
  Description: 'description',
101
104
  Typography: 'text',
102
105
  Badge: 'badge',
106
+ label: 'label',
103
107
  button: 'label',
104
108
  Button: 'label',
105
109
  span: 'label',
package/src/arc/index.cjs CHANGED
@@ -256,7 +256,7 @@ async function runDenebArcAsync(projectDir, projectName, options = {}) {
256
256
  0
257
257
  );
258
258
  const actionCount = analyses.reduce(
259
- (n, a) => n + (a.candidates || []).filter((c) => c.operation === 'split-action-contract' || c.kind === 'url').length,
259
+ (n, a) => n + (a.candidates || []).filter((c) => c.operation === 'split-action-contract' || c.operation === 'form-submit-action' || c.kind === 'url').length,
260
260
  0
261
261
  );
262
262
  printer.printScan(profile, graph, candidateCount, actionCount);
@@ -397,7 +397,7 @@ function runDenebArcSync(projectDir, projectName, options = {}) {
397
397
  0
398
398
  );
399
399
  const actionCount = analyses.reduce(
400
- (n, a) => n + (a.candidates || []).filter((c) => c.operation === 'split-action-contract' || c.kind === 'url').length,
400
+ (n, a) => n + (a.candidates || []).filter((c) => c.operation === 'split-action-contract' || c.operation === 'form-submit-action' || c.kind === 'url').length,
401
401
  0
402
402
  );
403
403
  printer.printScan(profile, graph, candidateCount, actionCount);
@@ -118,6 +118,7 @@ function mapOperation(operation) {
118
118
  case 'extract-url':
119
119
  return 'url-extraction';
120
120
  case 'split-action-contract':
121
+ case 'form-submit-action':
121
122
  return 'contract-split';
122
123
  case 'style-bind':
123
124
  return 'style-bind';
@@ -9,7 +9,7 @@ const { appendStyleBindTransforms } = require('./style-candidates.cjs');
9
9
  function recipeBoost(candidate, recipe) {
10
10
  if (!recipe) return 0;
11
11
  let boost = 0;
12
- if (recipe.actionRules?.splitActionAndLabel && candidate.operation === 'split-action-contract') boost += 0.03;
12
+ if (recipe.actionRules?.splitActionAndLabel && (candidate.operation === 'split-action-contract' || candidate.operation === 'form-submit-action')) boost += 0.03;
13
13
  const keywords = recipe.signatures?.keywords || [];
14
14
  const hay = `${candidate.tag} ${candidate.value || ''} ${candidate.label || ''} ${candidate.file || ''}`.toLowerCase();
15
15
  if (keywords.some((kw) => hay.includes(String(kw).toLowerCase()))) boost += 0.02;
@@ -66,8 +66,8 @@ function planTransformations({ profile, analyses, recipe }) {
66
66
  }
67
67
 
68
68
  const extra = { ...(candidate.extra || {}) };
69
- if (candidate.operation === 'split-action-contract') {
70
- extra.action = extra.action || classifyHref(candidate.value);
69
+ if (candidate.operation === 'split-action-contract' || candidate.operation === 'form-submit-action') {
70
+ extra.action = extra.action || (candidate.operation === 'form-submit-action' ? 'form-submit' : classifyHref(candidate.value));
71
71
  extra.paired = true;
72
72
  }
73
73
 
@@ -107,7 +107,7 @@ function planTransformations({ profile, analyses, recipe }) {
107
107
  continue;
108
108
  }
109
109
 
110
- if (candidate.operation === 'split-action-contract') {
110
+ if (candidate.operation === 'split-action-contract' || candidate.operation === 'form-submit-action') {
111
111
  const urlName = inferFieldName('url', candidate.tag, candidate.label, extra);
112
112
  const labelName = inferFieldName('label', candidate.tag, candidate.label, { ...extra, paired: true });
113
113
  const actionSection = sectionForAction(scope, section, extra);
@@ -117,6 +117,9 @@ function planTransformations({ profile, analyses, recipe }) {
117
117
  transform.labelField = uniquePath(usedPaths, sibling.join('.'));
118
118
  transform.fieldType = 'url';
119
119
  transform.labelFieldType = 'text';
120
+ if (candidate.operation === 'form-submit-action') {
121
+ transform.formContext = true;
122
+ }
120
123
  } else if (candidate.operation === 'extract-url') {
121
124
  transform.field = buildFieldPath({
122
125
  scope,
@@ -238,7 +241,7 @@ function sectionForAction(scope, section, extra) {
238
241
  if (scope === 'common' && (extra.social || ['instagram', 'facebook', 'twitter', 'tiktok', 'youtube', 'linkedin'].includes(extra.action))) {
239
242
  return 'footer';
240
243
  }
241
- if (['whatsapp', 'phone', 'email', 'directions', 'location'].includes(extra.action)) {
244
+ if (['whatsapp', 'phone', 'email', 'directions', 'location', 'form-submit'].includes(extra.action)) {
242
245
  return section || 'contact';
243
246
  }
244
247
  if (extra.action === 'shop') {
@@ -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');
@@ -392,7 +394,7 @@ function confidenceFor(kind, extras = {}) {
392
394
  if (extras.icon) return 0.1;
393
395
  if (kind === 'url' && extras.action === 'whatsapp') return 0.96;
394
396
  if (kind === 'url' && extras.social) return 0.93;
395
- if (kind === 'split-action-contract') return 0.94;
397
+ if (kind === 'split-action-contract' || kind === 'form-submit-action') return 0.94;
396
398
  if (kind === 'text' && HEADING_TAGS.has(extras.tag)) return 0.95;
397
399
  if (kind === 'text' && extras.tag === 'p') return 0.9;
398
400
  if (kind === 'image') return 0.88;
@@ -562,14 +564,47 @@ function analyzeFile({ code, relativeFile, profile, graph, ownerScope, component
562
564
  });
563
565
  }
564
566
 
567
+ const insideForm = parents.some((p) => FORM_CONTAINER_TAGS.has(p));
568
+
565
569
  const actionableHref = href || (name === 'Button' ? getJsxAttributeLiteral(node, 'href') : null);
566
570
  const innerText = textInfo.dynamic ? '' : textInfo.text;
567
571
  const typeAttr = getJsxAttributeLiteral(node, 'type');
568
572
  const isSubmit = typeAttr === 'submit';
569
573
 
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));
574
+ // Check for action intent via classifyActionIntent (covers Form Submit, WhatsApp, Call/Phone, Directions, Location, Shop, Email)
575
+ const actionIntent = !apiOwned ? classifyActionIntent(innerText, actionableHref) : null;
576
+
577
+ const isFormSubmit =
578
+ !apiOwned &&
579
+ (ACTION_TAGS.has(name) || isLikelyCtaClass(className)) &&
580
+ (isSubmit ||
581
+ (insideForm && (actionIntent?.action === 'form-submit' || (innerText && !actionableHref))) ||
582
+ actionIntent?.action === 'form-submit');
583
+
584
+ if (isFormSubmit && innerText && !textInfo.dynamic) {
585
+ usedLocs.add(loc);
586
+ const resolvedHref = actionableHref || (actionIntent ? actionIntent.defaultUrl : 'https://wa.me/1234567890');
587
+ candidates.push({
588
+ ...baseMeta,
589
+ kind: 'form-submit-action',
590
+ operation: 'form-submit-action',
591
+ value: resolvedHref,
592
+ label: innerText,
593
+ extra: {
594
+ action: 'form-submit',
595
+ external: true,
596
+ insideForm,
597
+ isSubmit,
598
+ },
599
+ confidence: confidenceFor('form-submit-action'),
600
+ reason: 'form-submit-whatsapp-dispatch',
601
+ fingerprint: fingerprintCandidate({ tag: name, kind: 'form-submit-action', action: 'form-submit' }),
602
+ });
603
+ this.traverse(pathNode);
604
+ return;
605
+ }
606
+
607
+ const isAction = !isSubmit && (Boolean(actionableHref) || Boolean(actionIntent)) && (ACTION_TAGS.has(name) || isLikelyCtaClass(className));
573
608
 
574
609
  if (isAction && (actionableHref || actionIntent)) {
575
610
  const action = actionIntent ? actionIntent.action : classifyHref(actionableHref);
@@ -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,27 @@ 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 (!hasJsxAttribute(node, 'data-preview-static')) {
163
+ node.openingElement.attributes.push(
164
+ b.jsxAttribute(b.jsxIdentifier('data-preview-static'), b.stringLiteral('action-button'))
165
+ );
166
+ }
167
+ if (hasJsxAttribute(node, 'data-preview-field-path')) {
168
+ node.openingElement.attributes = node.openingElement.attributes.filter(
169
+ (attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'data-preview-field-path')
170
+ );
171
+ }
172
+ splitActionChildren(node, transform.labelField, transform.labelFallback || '');
173
+ wrapHiddenUrlSibling(pathNode, transform.urlField, transform.fallback);
174
+ return;
175
+ }
155
176
  if (transform.operation === 'split-action-contract') {
156
177
  const tagName = getJsxName(node);
157
178
  if (tagName === 'button') {
@@ -625,6 +646,7 @@ function applyFilePlan(filePlan, profile) {
625
646
 
626
647
  const supported = new Set([
627
648
  'split-action-contract',
649
+ 'form-submit-action',
628
650
  'extract-url',
629
651
  'extract-image',
630
652
  'extract-alt',