@deneb-ui/cli 2.0.46 → 2.0.47

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.47",
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.47",
53
53
  "@octokit/rest": "^22.0.1",
54
54
  "adm-zip": "^0.6.0",
55
55
  "dotenv": "^17.4.2",
@@ -626,3 +626,78 @@ export { useSiteData, contentText };
626
626
  assert.match(out, /export\s*\{[^}]*useSiteData[^}]*\}\s*from\s*['"]@deneb-ui\/ui['"]/);
627
627
  assert.doesNotMatch(out, /import\s*\{[^}]*useSiteData/);
628
628
  });
629
+
630
+ test('toCamel and optionalMember safely handle strings starting with numbers without syntax errors', () => {
631
+ const { toCamel } = require('../field-paths.cjs');
632
+ const { optionalMember, parseSource } = require('../ast.cjs');
633
+ const recast = require('recast');
634
+
635
+ // toCamel prefixes identifiers starting with a number
636
+ assert.equal(toCamel(['1800840AURA']), 'item1800840aura');
637
+ assert.equal(toCamel(['25-Min', 'Express']), 'item25MinExpress');
638
+
639
+ // optionalMember safely falls back to computed string literal for non-identifier keys
640
+ const chain = optionalMember(['siteData', 'content', 'home', '100EncryptedLabel']);
641
+ const code = recast.print(chain).code;
642
+ assert.equal(code, 'siteData?.content?.home?.["100EncryptedLabel"]');
643
+
644
+ // Verify it parses as valid JS without syntax errors
645
+ assert.doesNotThrow(() => parseSource(`const x = ${code};`, 'test.tsx'));
646
+ });
647
+
648
+ test('bindArrayDeclaration transforms module-scope array into DEFAULT_ fallback in client components', () => {
649
+ const clientCode = `'use client';
650
+ import React from 'react';
651
+
652
+ const BRANDS = [
653
+ { name: 'Apple' },
654
+ { name: 'Samsung' },
655
+ ];
656
+
657
+ export function BrandMarquee() {
658
+ return (
659
+ <div>
660
+ {BRANDS.map((b) => (
661
+ <div key={b.name} className="brand-item">
662
+ <span>{b.name}</span>
663
+ </div>
664
+ ))}
665
+ </div>
666
+ );
667
+ }
668
+ `;
669
+
670
+ const profile = {
671
+ root: os.tmpdir(),
672
+ framework: 'nextjs',
673
+ router: 'next-app',
674
+ language: 'typescript',
675
+ cssSystems: ['tailwind'],
676
+ hasSrc: true,
677
+ aliasMap: { '@/*': ['src/*'] },
678
+ };
679
+
680
+ const analysis = analyzeFile({
681
+ code: clientCode,
682
+ relativeFile: 'src/components/BrandMarquee.tsx',
683
+ profile,
684
+ graph: { sharedFiles: [] },
685
+ ownerScope: 'home',
686
+ componentMeta: { name: 'BrandMarquee', role: 'about' },
687
+ });
688
+ analysis.code = clientCode;
689
+ analysis.relativeFile = 'src/components/BrandMarquee.tsx';
690
+
691
+ const plan = planTransformations({ profile, analyses: [analysis] });
692
+ const result = applyFilePlan(plan.files[0], profile);
693
+
694
+ assert.equal(result.changed, true);
695
+ // Module-scope array renamed to DEFAULT_BRANDS with literal array preserved
696
+ assert.match(result.code, /const DEFAULT_BRANDS = \[\s*\{\s*name:\s*['"]Apple['"]\s*\}/);
697
+ // Inside component body: useSiteData hook followed by dynamic BRANDS binding
698
+ assert.match(result.code, /const siteData = useSiteData\(\);/);
699
+ assert.match(result.code, /const BRANDS = siteData\?\.content\?\.home\?\.BRANDS \?\? DEFAULT_BRANDS;/);
700
+ // Verify AST parses cleanly
701
+ assert.doesNotThrow(() => parseSource(result.code, 'BrandMarquee.tsx'));
702
+ });
703
+
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) {
@@ -238,7 +238,7 @@ function wrapLiteralTextChildren(node, fieldPath, fallback) {
238
238
  * emitted as JSX template literals (`items[${index}].title`) so added and
239
239
  * reordered items stay editable, which is what strict mode requires.
240
240
  */
241
- function applyCollectionTransform(ast, transform) {
241
+ function applyCollectionTransform(ast, transform, isClient = false) {
242
242
  const listPath = transform.listField;
243
243
  const binding = transform.itemParam;
244
244
  if (!listPath || !binding) return false;
@@ -290,7 +290,7 @@ function applyCollectionTransform(ast, transform) {
290
290
  );
291
291
  }
292
292
 
293
- return bindArrayDeclaration(ast, mapCall, listPath);
293
+ return bindArrayDeclaration(ast, mapCall, listPath, isClient);
294
294
  }
295
295
 
296
296
  function findMapCall(ast, loc) {
@@ -376,7 +376,40 @@ function findListContainer(mapCallPath) {
376
376
  return null;
377
377
  }
378
378
 
379
- function bindArrayDeclaration(ast, mapCallPath, listPath) {
379
+ function isModuleLevel(pathNode) {
380
+ let current = pathNode.parentPath || pathNode.parent;
381
+ while (current) {
382
+ const type = current.node?.type;
383
+ if (
384
+ type === 'FunctionDeclaration' ||
385
+ type === 'FunctionExpression' ||
386
+ type === 'ArrowFunctionExpression'
387
+ ) {
388
+ return false;
389
+ }
390
+ if (type === 'Program') return true;
391
+ current = current.parentPath || current.parent;
392
+ }
393
+ return true;
394
+ }
395
+
396
+ function findEnclosingFunction(pathNode) {
397
+ let current = pathNode.parentPath || pathNode.parent;
398
+ while (current) {
399
+ const type = current.node?.type;
400
+ if (
401
+ type === 'FunctionDeclaration' ||
402
+ type === 'FunctionExpression' ||
403
+ type === 'ArrowFunctionExpression'
404
+ ) {
405
+ return current;
406
+ }
407
+ current = current.parentPath || current.parent;
408
+ }
409
+ return null;
410
+ }
411
+
412
+ function bindArrayDeclaration(ast, mapCallPath, listPath, isClient = false) {
380
413
  const arrayName = mapCallPath.node.callee.object?.name;
381
414
  if (!arrayName) return false;
382
415
  let bound = false;
@@ -392,6 +425,33 @@ function bindArrayDeclaration(ast, mapCallPath, listPath) {
392
425
  this.traverse(pathNode);
393
426
  return;
394
427
  }
428
+
429
+ if (isClient && isModuleLevel(pathNode)) {
430
+ const defaultName = 'DEFAULT_' + arrayName;
431
+ node.id.name = defaultName;
432
+ const fnPath = findEnclosingFunction(mapCallPath);
433
+ if (fnPath && fnPath.node.body?.type === 'BlockStatement') {
434
+ const body = fnPath.node.body.body;
435
+ const already = body.some((stmt) => recast.print(stmt).code.includes(`const ${arrayName} =`));
436
+ if (!already) {
437
+ const localDecl = b.variableDeclaration('const', [
438
+ b.variableDeclarator(
439
+ b.identifier(arrayName),
440
+ siteDataListBinding(listPath.split('.'), b.identifier(defaultName))
441
+ ),
442
+ ]);
443
+ const hookIdx = body.findIndex((stmt) => recast.print(stmt).code.includes('useSiteData'));
444
+ if (hookIdx >= 0) {
445
+ body.splice(hookIdx + 1, 0, localDecl);
446
+ } else {
447
+ body.unshift(localDecl);
448
+ }
449
+ }
450
+ }
451
+ bound = true;
452
+ return false;
453
+ }
454
+
395
455
  node.init = siteDataListBinding(listPath.split('.'), node.init);
396
456
  bound = true;
397
457
  return false;
@@ -549,7 +609,7 @@ function applyFilePlan(filePlan, profile) {
549
609
 
550
610
  if (transform.operation === 'collection-conversion') {
551
611
  try {
552
- if (applyCollectionTransform(ast, transform)) applied++;
612
+ if (applyCollectionTransform(ast, transform, isClient)) applied++;
553
613
  else failures.push({ loc: transform.loc, reason: 'collection-not-bindable' });
554
614
  } catch (err) {
555
615
  failures.push({ loc: transform.loc, reason: err.message });