@deneb-ui/cli 2.0.50 → 2.0.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/index.js CHANGED
@@ -187,6 +187,18 @@ function detectPages(projectDir) {
187
187
  continue;
188
188
  }
189
189
  if (!foundRoutes.has('/' + routeName)) {
190
+ const pageFile = ['page.tsx', 'page.jsx', 'page.js']
191
+ .map((name) => path.join(cDir, routeName, name))
192
+ .find((file) => fs.existsSync(file));
193
+ const pagesFile = ['tsx', 'jsx', 'js']
194
+ .map((ext) => path.join(cDir, `${routeName}.${ext}`))
195
+ .find((file) => fs.existsSync(file));
196
+ if (!pageFile && !pagesFile && path.basename(cDir) !== 'pages' && path.basename(cDir) !== 'src') {
197
+ continue;
198
+ }
199
+ if (!pageFile && path.basename(cDir) !== 'pages' && !cDir.endsWith(`${path.sep}pages`)) {
200
+ continue;
201
+ }
190
202
  foundRoutes.add('/' + routeName);
191
203
  const label = routeName
192
204
  .replace(/[-_]/g, ' ')
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.52",
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.52",
53
53
  "@octokit/rest": "^22.0.1",
54
54
  "adm-zip": "^0.6.0",
55
55
  "dotenv": "^17.4.2",
@@ -158,8 +158,8 @@ test('AST transformer preserves className and uses nullish fallbacks', () => {
158
158
  assert.match(result.code, /data-preview-field-path=/);
159
159
  assert.match(result.code, /\?\?/);
160
160
  assert.match(result.code, /<span[\s\S]*data-preview-field-path="/);
161
- assert.match(result.code, /<span[^>]*hidden[^>]*data-preview-field-path="/);
162
- assert.match(result.code, /data-preview-static="action-link"/);
161
+ assert.match(result.code, /<a\b[^>]*data-preview-field-path=/);
162
+ assert.doesNotMatch(result.code, /<span[^>]*hidden[^>]*data-preview-field-path/);
163
163
  assert.doesNotMatch(result.code, /'use client'/);
164
164
  assert.match(result.code, /site-data\.json|@\/data\/site-data\.json/);
165
165
  assert.match(result.code, /data-preview-style-target=/);
@@ -409,12 +409,14 @@ test('static-array collections become list contracts without changing render log
409
409
  assert.equal(siteData.content.home.products[0].title, 'Minimalist Smart Watch');
410
410
  });
411
411
 
412
- test('collections holding component references are left alone', () => {
412
+ test('collections holding component references still bind primitive item fields', () => {
413
413
  const dir = copyOf(STOREFRONT_FIXTURE);
414
414
  silence(() => runDenebArc(dir, 'acme-store', { telemetry: 'off' }));
415
415
  const features = fs.readFileSync(path.join(dir, 'src', 'components', 'Features.tsx'), 'utf8');
416
- assert.ok(!features.includes('data-preview-list-path'), 'icon component refs are not merchant content');
416
+ assert.match(features, /data-preview-list-path=/);
417
417
  assert.match(features, /icon: Truck/);
418
+ assert.match(features, /data-preview-static="component-ref"/);
419
+ assert.match(features, /data-preview-field-path=\{`[^`]*\.title`\}/);
418
420
  });
419
421
 
420
422
  test('literal text inside a broad container is wrapped instead of marked illegally', () => {
@@ -435,8 +437,8 @@ test('shadcn Button asChild keeps the action on the link and the label in a span
435
437
  assert.match(hero, /<Button asChild>/);
436
438
  assert.match(hero, /href=\{siteData\?\.content\?\.home\?\.hero\?\.shopCollectionUrl \?\? "\/products"\}/);
437
439
  assert.match(hero, /<span[\s\S]*data-preview-field-path="home\.hero\.shopCollectionLabel"/);
438
- // The decorative icon stays static.
439
- assert.match(hero, /<ArrowRight className="ml-2 size-4" aria-hidden="true" \/>/);
440
+ // The decorative icon stays static (may also receive data-preview-static).
441
+ assert.match(hero, /<ArrowRight[^>]*className="ml-2 size-4"[^>]*aria-hidden="true"/);
440
442
  });
441
443
 
442
444
  test('a second init run converges on identical sources and manifest', () => {
@@ -729,7 +731,12 @@ test('classifyActionIntent identifies action keywords and assigns correct fallba
729
731
  assert.equal(shop?.defaultUrl, '/shop');
730
732
  assert.equal(shop?.external, false);
731
733
 
732
- const nonAction = classifyActionIntent('Submit Form', null);
734
+ const formSubmit = classifyActionIntent('Submit Form', null);
735
+ assert.equal(formSubmit?.action, 'form-submit');
736
+ assert.equal(formSubmit?.defaultUrl, 'https://wa.me/1234567890');
737
+ assert.equal(formSubmit?.external, true);
738
+
739
+ const nonAction = classifyActionIntent('Random Content Text', null);
733
740
  assert.equal(nonAction, null);
734
741
  });
735
742
 
@@ -816,11 +823,12 @@ export function ActionPanel() {
816
823
  assert.match(result.code, /<a\s+[^>]*href=\{siteData\?\.content\?\.home\?\.hero\?\.whatsappUrl \?\? "https:\/\/wa\.me\/1234567890"\}/);
817
824
  assert.match(result.code, /target="_blank"/);
818
825
  assert.match(result.code, /rel="noopener noreferrer"/);
819
- assert.match(result.code, /data-preview-static="action-link"/);
826
+ // URL field path is bound directly to outer <a> element (never hidden, never static conflict)
827
+ assert.match(result.code, /<a\s+[^>]*data-preview-field-path=\{?"home\.hero\.whatsappUrl"?\}/);
820
828
  // Text label wrapped in editable span
821
829
  assert.match(result.code, /<span\s+data-preview-field-path="home\.hero\.whatsappLabel"[^>]*>\{siteData\?\.content\?\.home\?\.hero\?\.whatsappLabel \?\? "Order on WhatsApp"\}<\/span>/);
822
- // Hidden URL span present for Fivora contract
823
- assert.match(result.code, /<span hidden aria-hidden="true" data-preview-field-path="home\.hero\.whatsappUrl">/);
830
+ // Never emit hidden preview markers
831
+ assert.doesNotMatch(result.code, /<span[^>]*hidden[^>]*data-preview-field-path/);
824
832
  // No parse errors
825
833
  assert.doesNotThrow(() => parseSource(result.code, 'ActionPanel.tsx'));
826
834
  });
@@ -1215,3 +1223,204 @@ export function CartDrawer() {
1215
1223
  assert.match(splitAction.urlField, /whatsapp/i);
1216
1224
  });
1217
1225
 
1226
+ test('classifyActionIntent recognizes form submit action keywords', () => {
1227
+ const submit = classifyActionIntent('Submit', null);
1228
+ assert.equal(submit?.action, 'form-submit');
1229
+ assert.equal(submit?.defaultUrl, 'https://wa.me/1234567890');
1230
+
1231
+ const sendMessage = classifyActionIntent('Send Message', null);
1232
+ assert.equal(sendMessage?.action, 'form-submit');
1233
+
1234
+ const confirmBooking = classifyActionIntent('Confirm Booking', null);
1235
+ assert.equal(confirmBooking?.action, 'form-submit');
1236
+
1237
+ const getQuote = classifyActionIntent('Get Quote', null);
1238
+ assert.equal(getQuote?.action, 'form-submit');
1239
+ });
1240
+
1241
+ test('semantic engine recognizes form inputs, labels, placeholders, and form-submit-action buttons', () => {
1242
+ const code = `
1243
+ export function BookingForm() {
1244
+ return (
1245
+ <form className="booking-form">
1246
+ <h2>Book Appointment</h2>
1247
+ <label>Full Name</label>
1248
+ <input type="text" placeholder="Enter your full name" />
1249
+ <label>Email Address</label>
1250
+ <input type="email" placeholder="you@example.com" />
1251
+ <label>Special Instructions</label>
1252
+ <textarea placeholder="Describe your request..." />
1253
+ <button type="submit">Confirm Booking</button>
1254
+ </form>
1255
+ );
1256
+ }
1257
+ `;
1258
+ const profile = {
1259
+ root: os.tmpdir(),
1260
+ framework: 'nextjs',
1261
+ router: 'next-app',
1262
+ language: 'typescript',
1263
+ cssSystems: ['tailwind'],
1264
+ hasSrc: true,
1265
+ };
1266
+
1267
+ const analysis = analyzeFile({
1268
+ code,
1269
+ relativeFile: 'src/components/BookingForm.tsx',
1270
+ profile,
1271
+ graph: { sharedFiles: [] },
1272
+ ownerScope: 'home',
1273
+ componentMeta: { name: 'BookingForm', role: 'form' },
1274
+ });
1275
+
1276
+ const placeholders = analysis.candidates.filter((c) => c.kind === 'placeholder');
1277
+ assert.equal(placeholders.length, 3, 'expected 3 placeholder candidates');
1278
+
1279
+ const labels = analysis.candidates.filter((c) => c.kind === 'text' && c.tag === 'label');
1280
+ assert.equal(labels.length, 3, 'expected 3 label candidates');
1281
+
1282
+ const submitAction = analysis.candidates.find((c) => c.kind === 'form-submit-action');
1283
+ assert.ok(submitAction, 'expected form-submit-action candidate');
1284
+ assert.equal(submitAction.label, 'Confirm Booking');
1285
+ assert.equal(submitAction.extra.action, 'form-submit');
1286
+ assert.equal(submitAction.extra.insideForm, true);
1287
+ });
1288
+
1289
+ test('planner and transformer make entire form editable with submit button and WhatsApp URL binding', () => {
1290
+ const code = `
1291
+ import React from 'react';
1292
+
1293
+ export function ContactSection() {
1294
+ return (
1295
+ <form className="contact-form">
1296
+ <label>Your Name</label>
1297
+ <input type="text" placeholder="John Doe" />
1298
+ <button type="submit">Send Message</button>
1299
+ </form>
1300
+ );
1301
+ }
1302
+ `;
1303
+ const profile = {
1304
+ root: os.tmpdir(),
1305
+ framework: 'nextjs',
1306
+ router: 'next-app',
1307
+ language: 'typescript',
1308
+ cssSystems: ['tailwind'],
1309
+ hasSrc: true,
1310
+ aliasMap: { '@/*': ['src/*'] },
1311
+ };
1312
+
1313
+ const analysis = analyzeFile({
1314
+ code,
1315
+ relativeFile: 'src/components/ContactSection.tsx',
1316
+ profile,
1317
+ graph: { sharedFiles: [] },
1318
+ ownerScope: 'home',
1319
+ componentMeta: { name: 'ContactSection', role: 'contact' },
1320
+ });
1321
+ analysis.code = code;
1322
+ analysis.relativeFile = 'src/components/ContactSection.tsx';
1323
+
1324
+ const plan = planTransformations({
1325
+ profile,
1326
+ analyses: [analysis],
1327
+ });
1328
+
1329
+ const filePlan = plan.files[0];
1330
+ const formSubmit = filePlan.transformations.find((t) => t.operation === 'form-submit-action');
1331
+ assert.ok(formSubmit, 'form-submit-action must be planned');
1332
+ assert.match(formSubmit.urlField, /formWhatsappUrl/i);
1333
+ assert.match(formSubmit.labelField, /formSubmitLabel/i);
1334
+
1335
+ const transformed = applyFilePlan(filePlan, profile);
1336
+ assert.ok(transformed.changed, 'file must be changed');
1337
+ assert.match(transformed.code, /data-preview-field-path/);
1338
+ assert.match(transformed.code, /formSubmitLabel/);
1339
+ assert.match(transformed.code, /type="submit"/);
1340
+ assert.doesNotMatch(transformed.code, /<span[^>]*hidden/);
1341
+ assert.doesNotMatch(transformed.code, /data-preview-static[\s\S]*data-preview-field-path/);
1342
+ });
1343
+
1344
+ test('auditMarkerPlacement detects and rejects hidden preview markers', () => {
1345
+ const hiddenCode = `
1346
+ <div>
1347
+ <span className="hidden" aria-hidden="true" data-preview-field-path="home.hero.buttonUrl">https://wa.me/123</span>
1348
+ <span>Click me</span>
1349
+ </div>
1350
+ `;
1351
+ const errors = contract.auditMarkerPlacement(hiddenCode, 'TestHidden.tsx');
1352
+ assert.ok(errors.some((e) => e.includes('data-preview field/list/item marker is hidden')));
1353
+ });
1354
+
1355
+ test('auditCoupledListMarkers detects and rejects coupling parallel list arrays by index', () => {
1356
+ const coupledCode = `
1357
+ <div data-preview-item-path="seasonal.items[0]">
1358
+ <span data-preview-field-path="seasonal.preOrderCta[0].buttonLabel">Pre-Order</span>
1359
+ </div>
1360
+ `;
1361
+ const errors = contract.auditCoupledListMarkers(coupledCode, 'TestCoupled.tsx');
1362
+ assert.ok(errors.some((e) => e.includes('belongs to a different list')));
1363
+ });
1364
+
1365
+ test('detectedPages without a page file are not invented in the manifest', () => {
1366
+ const dir = copyOf(FIXTURE);
1367
+ silence(() =>
1368
+ runDenebArc(dir, 'basic-store', {
1369
+ telemetry: 'off',
1370
+ detectedPages: [{ id: 'contact', label: 'Contact', route: '/contact', required: true }],
1371
+ })
1372
+ );
1373
+ const { manifest } = auditFivora(dir);
1374
+ assert.ok(!manifest.pages.some((page) => page.id === 'contact' || page.route === '/contact'));
1375
+ });
1376
+
1377
+ test('learning records contract failure instead of success', () => {
1378
+ const { honestOutcome } = require('../learning.cjs');
1379
+ assert.equal(
1380
+ honestOutcome({ syntaxPassed: true, contractPassed: true, fivoraContractPassed: false }, 'success'),
1381
+ 'failure'
1382
+ );
1383
+ assert.equal(
1384
+ honestOutcome({ syntaxPassed: true, contractPassed: true, fivoraContractPassed: true, uncoveredVisibleText: 0 }, 'success'),
1385
+ 'success'
1386
+ );
1387
+ });
1388
+
1389
+ test('residual pass marks leftover decorative copy static with a reason', () => {
1390
+ const { applyResidualPass } = require('../residual.cjs');
1391
+ const code = `export function Chrome() { return <button aria-hidden="true">Close</button>; }`;
1392
+ const result = applyResidualPass({
1393
+ code,
1394
+ file: 'src/components/Chrome.tsx',
1395
+ ownerScope: 'home',
1396
+ usedPaths: new Set(),
1397
+ });
1398
+ assert.equal(result.changed, true);
1399
+ assert.match(result.code, /data-preview-static="[^"]+"/);
1400
+ });
1401
+
1402
+ test('style-bind grid and card attributes are written onto collection nodes', () => {
1403
+ const dir = copyOf(STOREFRONT_FIXTURE);
1404
+ silence(() => runDenebArc(dir, 'acme-store', { telemetry: 'off' }));
1405
+ const grid = fs.readFileSync(path.join(dir, 'src', 'components', 'ProductGrid.tsx'), 'utf8');
1406
+ assert.match(grid, /data-preview-style-type="grid"/);
1407
+ assert.match(grid, /data-preview-style-type="card"/);
1408
+ assert.match(grid, /data-preview-style-target="home\.products\.grid"/);
1409
+ });
1410
+
1411
+ test('ContactActions stays mounted when all channels are empty', () => {
1412
+ const sourcePath = path.join(__dirname, '..', '..', '..', '..', '..', 'packages', 'deneb-ui', 'src', 'contact', 'ContactActions.tsx');
1413
+ const source = fs.readFileSync(sourcePath, 'utf8');
1414
+ assert.doesNotMatch(source, /if \(!hasPhone && !hasWhatsApp && !hasEmail\)/);
1415
+ assert.doesNotMatch(source, /return null/);
1416
+ });
1417
+
1418
+ test('empty-state source audit flags gated preview markers', () => {
1419
+ const errors = contract.auditEmptyStateSource(
1420
+ '{title && <span data-preview-field-path="home.hero.title">{title}</span>}',
1421
+ 'Hero.tsx'
1422
+ );
1423
+ assert.ok(errors.some((error) => error.includes('gated behind')));
1424
+ });
1425
+
1426
+
@@ -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',