@deneb-ui/cli 2.0.70 → 2.0.72

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
@@ -467,15 +467,49 @@ function getDefaultSiteData(projectName, pages) {
467
467
  secondaryColor: '#0a1931',
468
468
  accentColor: '#00adb5',
469
469
  backgroundColor: '#ffffff',
470
+ cardBackgroundColor: '#f8fafc',
470
471
  textColor: '#0f172a',
472
+ borderColor: '#e2e8f0',
471
473
  headingFont: 'Inter',
472
474
  bodyFont: 'Inter',
473
475
  baseSize: '16px',
474
476
  heroMinHeight: '70vh',
475
477
  sectionPadding: '4rem',
478
+ dark: {
479
+ primaryColor: '#00adb5',
480
+ secondaryColor: '#f8fafc',
481
+ accentColor: '#016a7e',
482
+ backgroundColor: '#0b0f19',
483
+ cardBackgroundColor: '#111827',
484
+ textColor: '#f9fafb',
485
+ borderColor: '#1f2937',
486
+ },
476
487
  },
477
488
  },
478
489
  },
490
+ theme: {
491
+ primaryColor: '#016a7e',
492
+ secondaryColor: '#0a1931',
493
+ accentColor: '#00adb5',
494
+ backgroundColor: '#ffffff',
495
+ cardBackgroundColor: '#f8fafc',
496
+ textColor: '#0f172a',
497
+ borderColor: '#e2e8f0',
498
+ headingFont: 'Inter',
499
+ bodyFont: 'Inter',
500
+ baseSize: '16px',
501
+ heroMinHeight: '70vh',
502
+ sectionPadding: '4rem',
503
+ dark: {
504
+ primaryColor: '#00adb5',
505
+ secondaryColor: '#f8fafc',
506
+ accentColor: '#016a7e',
507
+ backgroundColor: '#0b0f19',
508
+ cardBackgroundColor: '#111827',
509
+ textColor: '#f9fafb',
510
+ borderColor: '#1f2937',
511
+ },
512
+ },
479
513
  requirements: {
480
514
  requiredPages: pages.filter((p) => p.required).map((p) => p.id),
481
515
  requiredFeatures: [],
@@ -932,6 +966,31 @@ async function initProject(targetInput, options = {}) {
932
966
  }
933
967
  }
934
968
 
969
+ // 4.6. Ensure Root Layout instruments SiteDataProvider
970
+ const appLayoutCandidates = [
971
+ path.join(targetDir, 'src', 'app', 'layout.tsx'),
972
+ path.join(targetDir, 'src', 'app', 'layout.jsx'),
973
+ path.join(targetDir, 'app', 'layout.tsx'),
974
+ path.join(targetDir, 'app', 'layout.jsx'),
975
+ ];
976
+ const targetLayoutFile = appLayoutCandidates.find((f) => fs.existsSync(f));
977
+ if (targetLayoutFile) {
978
+ try {
979
+ const layoutContent = fs.readFileSync(targetLayoutFile, 'utf8');
980
+ const hasProvider = /SiteDataProvider|DenebDataProvider|<Providers\b/.test(layoutContent);
981
+ if (!hasProvider) {
982
+ const { instrumentLayoutSource } = require('../src/arc/transformer.cjs');
983
+ const instrumented = instrumentLayoutSource(layoutContent, '@/data/site-data.json', '@deneb-ui/ui');
984
+ if (instrumented.updated && instrumented.code !== layoutContent) {
985
+ fs.writeFileSync(targetLayoutFile, instrumented.code, 'utf8');
986
+ console.log(`\x1b[32m✔ Instrumented\x1b[0m ${path.relative(targetDir, targetLayoutFile)} with <SiteDataProvider>`);
987
+ }
988
+ }
989
+ } catch (layoutErr) {
990
+ // Non-blocking layout instrumentation
991
+ }
992
+ }
993
+
935
994
  // 4. Update package.json scripts
936
995
  pkg.scripts = pkg.scripts || {};
937
996
  const scriptsToAdd = {
@@ -1030,6 +1089,15 @@ async function initProject(targetInput, options = {}) {
1030
1089
  }
1031
1090
  }
1032
1091
 
1092
+ // 7. Post-Init Self-Healing Preflight Check
1093
+ try {
1094
+ const { runDoctor } = require('../src/tools/deneb-doctor.cjs');
1095
+ console.log(`\n\x1b[36m🩺 Running DENEB post-init diagnostic & auto-healing pass...\x1b[0m`);
1096
+ runDoctor(targetDir, { fix: true, json: false });
1097
+ } catch (docErr) {
1098
+ // Non-blocking doctor check
1099
+ }
1100
+
1033
1101
  console.log(`\n\x1b[32m✔ Project initialization complete!\x1b[0m`);
1034
1102
  console.log(`\nYou can now run:`);
1035
1103
  console.log(` \x1b[36mnpm run lab\x1b[0m \x1b[90m# Launch Local Visual Editing Lab\x1b[0m`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deneb-ui/cli",
3
- "version": "2.0.70",
3
+ "version": "2.0.72",
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.70",
52
+ "@deneb-ui/core": "^2.0.72",
53
53
  "@octokit/rest": "^22.0.1",
54
54
  "adm-zip": "^0.6.0",
55
55
  "dotenv": "^17.4.2",
@@ -395,7 +395,7 @@ test('static-array collections become list contracts without changing render log
395
395
  assert.match(grid, /data-preview-item-path=\{`home\.products\[\$\{index\}\]`\}/);
396
396
  assert.match(grid, /data-preview-field-path=\{`home\.products\[\$\{index\}\]\.title`\}/);
397
397
  // The array is site-data backed with the developer's literal as fallback.
398
- assert.match(grid, /const products = siteData\?\.content\?\.home\?\.products \?\? \[/);
398
+ assert.match(grid, /const products(?::\s*any\[\])? = siteData\?\.content\?\.home\?\.products \?\? \[/);
399
399
  // Render logic is untouched: items are still read off the map variable.
400
400
  assert.match(grid, /\{product\.title\}/);
401
401
  assert.match(grid, /className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3"/);
@@ -701,7 +701,7 @@ export function BrandMarquee() {
701
701
  assert.match(result.code, /const DEFAULT_BRANDS = \[\s*\{\s*name:\s*['"]Apple['"]\s*\}/);
702
702
  // Inside component body: useSiteData hook followed by dynamic BRANDS binding
703
703
  assert.match(result.code, /const siteData = useSiteData\(\);/);
704
- assert.match(result.code, /const BRANDS = siteData\?\.content\?\.home\?\.BRANDS \?\? DEFAULT_BRANDS;/);
704
+ assert.match(result.code, /const BRANDS(?::\s*any\[\])? = siteData\?\.content\?\.home\?\.BRANDS \?\? DEFAULT_BRANDS;/);
705
705
  // Verify AST parses cleanly
706
706
  assert.doesNotThrow(() => parseSource(result.code, 'BrandMarquee.tsx'));
707
707
  });
@@ -1471,5 +1471,245 @@ test('saveRecipeFromProject learns calibrated fixes and registers live product d
1471
1471
  } catch {}
1472
1472
  });
1473
1473
 
1474
+ test('applyCollectionTransform generates composite key for unkeyed map loops', () => {
1475
+ const { parseSource, printSource } = require('../ast.cjs');
1476
+ const { applyCollectionTransform } = require('../transformer.cjs');
1477
+ const code = `
1478
+ export function FeatureList() {
1479
+ const items = [{ id: '1', title: 'Speed' }];
1480
+ return (
1481
+ <div>
1482
+ {items.map((item, index) => (
1483
+ <div>{item.title}</div>
1484
+ ))}
1485
+ </div>
1486
+ );
1487
+ }
1488
+ `;
1489
+ const ast = parseSource(code, 'FeatureList.tsx');
1490
+ const recast = require('recast');
1491
+ const { locKey } = require('../ast.cjs');
1492
+ let targetLoc = null;
1493
+ recast.types.visit(ast, {
1494
+ visitJSXElement(p) {
1495
+ if (p.parent?.node?.type === 'ArrowFunctionExpression') {
1496
+ targetLoc = locKey(p.node);
1497
+ return false;
1498
+ }
1499
+ this.traverse(p);
1500
+ },
1501
+ });
1502
+ const transformed = applyCollectionTransform(ast, {
1503
+ loc: targetLoc,
1504
+ listField: 'home.features',
1505
+ itemParam: 'item',
1506
+ indexParam: 'index',
1507
+ });
1508
+ assert.ok(transformed);
1509
+ const out = printSource(ast, code);
1510
+ assert.match(out, /key=\{item\.id \|\| item\.slug \|\| item\.title \|\| item\.name \|\| index\}/);
1511
+ });
1512
+
1513
+ test('sanitizeContradictoryMarkers strips data-preview-static when element wraps editable descendants', () => {
1514
+ const { parseSource, printSource } = require('../ast.cjs');
1515
+ const { sanitizeContradictoryMarkers } = require('../transformer.cjs');
1516
+ const code = `
1517
+ export function Hero() {
1518
+ return (
1519
+ <section data-preview-static="hero-wrapper">
1520
+ <h1 data-preview-field-path="home.hero.title">Hello</h1>
1521
+ </section>
1522
+ );
1523
+ }
1524
+ `;
1525
+ const ast = parseSource(code, 'Hero.tsx');
1526
+ const cleaned = sanitizeContradictoryMarkers(ast);
1527
+ assert.equal(cleaned, 1);
1528
+ const out = printSource(ast, code);
1529
+ assert.doesNotMatch(out, /data-preview-static/);
1530
+ assert.match(out, /data-preview-field-path="home\.hero\.title"/);
1531
+ });
1532
+
1533
+ test('sanitizeContradictoryMarkers strips data-preview-static from broad containers even without editable descendants', () => {
1534
+ const { parseSource, printSource } = require('../ast.cjs');
1535
+ const { sanitizeContradictoryMarkers } = require('../transformer.cjs');
1536
+ const code = `
1537
+ export function Container() {
1538
+ return (
1539
+ <div data-preview-static="box-container">
1540
+ <p>Plain unannotated text</p>
1541
+ </div>
1542
+ );
1543
+ }
1544
+ `;
1545
+ const ast = parseSource(code, 'Container.tsx');
1546
+ const cleaned = sanitizeContradictoryMarkers(ast);
1547
+ assert.equal(cleaned, 1);
1548
+ const out = printSource(ast, code);
1549
+ assert.doesNotMatch(out, /data-preview-static/);
1550
+ assert.match(out, /<div\s*>/);
1551
+ });
1552
+
1553
+ test('healBroadContainerMarkers demotes data-preview-field-path from broad containers to inner leaf span', () => {
1554
+ const { parseSource, printSource } = require('../ast.cjs');
1555
+ const { healBroadContainerMarkers } = require('../transformer.cjs');
1556
+ const code = `
1557
+ export function Card() {
1558
+ return (
1559
+ <div data-preview-field-path="home.card.heading">
1560
+ Card Title Content
1561
+ </div>
1562
+ );
1563
+ }
1564
+ `;
1565
+ const ast = parseSource(code, 'Card.tsx');
1566
+ const healed = healBroadContainerMarkers(ast);
1567
+ assert.equal(healed, 1);
1568
+ const out = printSource(ast, code);
1569
+ assert.doesNotMatch(out, /<div[^>]*data-preview-field-path/);
1570
+ assert.match(out, /<span\s+data-preview-field-path="home\.card\.heading">/);
1571
+ });
1572
+
1573
+ test('healSectionOverflowHidden converts overflow-hidden to overflow-clip on section containers', () => {
1574
+ const { parseSource, printSource } = require('../ast.cjs');
1575
+ const { healSectionOverflowHidden } = require('../transformer.cjs');
1576
+ const code = `
1577
+ export function Showcase() {
1578
+ return (
1579
+ <section className="relative py-24 overflow-hidden bg-white">
1580
+ <span data-preview-field-path="home.title">Showcase</span>
1581
+ </section>
1582
+ );
1583
+ }
1584
+ `;
1585
+ const ast = parseSource(code, 'Showcase.tsx');
1586
+ const healed = healSectionOverflowHidden(ast);
1587
+ assert.equal(healed, 1);
1588
+ const out = printSource(ast, code);
1589
+ assert.doesNotMatch(out, /overflow-hidden/);
1590
+ assert.match(out, /overflow-clip/);
1591
+ });
1592
+
1593
+ test('injectSiteDataHook does not inject hook into helper sub-functions or functions that already have it', () => {
1594
+ const { parseSource, printSource } = require('../ast.cjs');
1595
+ const { injectSiteDataHook } = require('../transformer.cjs');
1596
+ const code = `
1597
+ import { useSiteData } from '@deneb-ui/ui';
1598
+
1599
+ function HelperIcon() {
1600
+ return <svg><path d="M0 0" /></svg>;
1601
+ }
1602
+
1603
+ export function MainSection() {
1604
+ const { siteData } = useSiteData();
1605
+ return <div>{siteData?.content?.home?.title}</div>;
1606
+ }
1607
+ `;
1608
+ const ast = parseSource(code, 'MainSection.tsx');
1609
+ const injected = injectSiteDataHook(ast);
1610
+ assert.equal(injected, false);
1611
+ const out = printSource(ast, code);
1612
+ assert.doesNotMatch(out, /function HelperIcon\(\)\s*\{\s*const \{ siteData \} = useSiteData\(\);/);
1613
+ });
1614
+
1615
+ test('learning loadFingerprintBoost returns verified boost for baseline trained fingerprints', () => {
1616
+ const { loadFingerprintBoost } = require('../learning.cjs');
1617
+ const boost1 = loadFingerprintBoost('leaf-static-marker');
1618
+ assert.equal(boost1.state, 'verified');
1619
+ assert.equal(boost1.boost, 0.08);
1620
+
1621
+ const boost2 = loadFingerprintBoost('section-overflow-clip');
1622
+ assert.equal(boost2.state, 'verified');
1623
+ assert.equal(boost2.boost, 0.08);
1624
+
1625
+ const boost3 = loadFingerprintBoost('empty-state-array-guard');
1626
+ assert.equal(boost3.state, 'verified');
1627
+ assert.equal(boost3.boost, 0.08);
1628
+ });
1629
+
1630
+ test('instrumentLayoutSource injects ThemeStyles and ThemeToggle with dual mode support into root layout', () => {
1631
+ const { instrumentLayoutSource } = require('../transformer.cjs');
1632
+ const code = `
1633
+ import React from 'react';
1634
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
1635
+ return (
1636
+ <html lang="en">
1637
+ <body>
1638
+ <main>{children}</main>
1639
+ </body>
1640
+ </html>
1641
+ );
1642
+ }
1643
+ `;
1644
+ const result = instrumentLayoutSource(code, '@/data/site-data.json', '@deneb-ui/ui');
1645
+ assert.equal(result.updated, true);
1646
+ assert.ok(result.code.includes('ThemeStyles'));
1647
+ assert.ok(result.code.includes('ThemeToggle'));
1648
+ assert.ok(result.code.includes('SiteDataProvider'));
1649
+ assert.ok(result.code.includes('enableDualMode'));
1650
+ assert.ok(result.code.includes('fixed bottom-6 left-6 z-40'));
1651
+ });
1652
+
1653
+ test('semantic prop harvester detects user-facing copy props and transformer binds them to siteData', () => {
1654
+ const { analyzeFile } = require('../semantic.cjs');
1655
+ const { planTransformations } = require('../planner.cjs');
1656
+ const { applyFilePlan } = require('../transformer.cjs');
1657
+ const code = `
1658
+ export function Showcase() {
1659
+ return (
1660
+ <div className="features">
1661
+ <FeatureCard title="Lightning Fast" description="Instant response times" />
1662
+ </div>
1663
+ );
1664
+ }
1665
+ `;
1666
+ const profile = { root: '/tmp/test', hasSrc: true, jsxFiles: ['Showcase.tsx'], routes: [{ id: 'home', route: '/' }] };
1667
+ const analysis = analyzeFile({
1668
+ code,
1669
+ relativeFile: 'Showcase.tsx',
1670
+ profile,
1671
+ graph: { routesByFile: { 'Showcase.tsx': ['home'] } },
1672
+ ownerScope: 'home',
1673
+ });
1674
+ const propCandidates = analysis.candidates.filter((c) => c.operation === 'extract-prop');
1675
+ assert.ok(propCandidates.length >= 2);
1676
+ assert.ok(propCandidates.some((c) => c.value === 'Lightning Fast' && c.extra?.propName === 'title'));
1677
+ assert.ok(propCandidates.some((c) => c.value === 'Instant response times' && c.extra?.propName === 'description'));
1678
+
1679
+ const plan = planTransformations({
1680
+ profile,
1681
+ analyses: [{ ...analysis, relativeFile: 'Showcase.tsx', code }],
1682
+ });
1683
+ const filePlan = plan.files[0];
1684
+ const transformed = applyFilePlan(filePlan, profile);
1685
+ assert.equal(transformed.changed, true);
1686
+ assert.ok(transformed.code.includes('title={siteData?.content?.home'));
1687
+ assert.ok(transformed.code.includes('description={siteData?.content?.home'));
1688
+ });
1689
+
1690
+ test('manifest buildSiteDataAndManifest creates default dual-mode palette with dark mode overrides', () => {
1691
+ const { buildSiteDataAndManifest } = require('../manifest.cjs');
1692
+ const profile = {
1693
+ packageName: 'test-store',
1694
+ hasSrc: true,
1695
+ routes: [{ id: 'home', label: 'Home', route: '/', required: true }],
1696
+ };
1697
+ const plan = { files: [], usedPaths: [] };
1698
+ const bundle = buildSiteDataAndManifest({
1699
+ projectDir: '/tmp/test',
1700
+ projectName: 'test-store',
1701
+ profile,
1702
+ plan,
1703
+ });
1704
+ assert.ok(bundle.siteData.theme);
1705
+ assert.ok(bundle.siteData.theme.dark);
1706
+ assert.equal(bundle.siteData.theme.backgroundColor, '#ffffff');
1707
+ assert.equal(bundle.siteData.theme.dark.backgroundColor, '#0b0f19');
1708
+ assert.equal(bundle.siteData.theme.dark.textColor, '#f9fafb');
1709
+ });
1710
+
1711
+
1712
+
1713
+
1474
1714
 
1475
1715
 
@@ -163,12 +163,12 @@ function classifyFieldType(kind, value) {
163
163
  if (kind === 'image') return 'image';
164
164
  if (kind === 'url') return 'url';
165
165
  if (kind === 'email' || (typeof value === 'string' && /^mailto:/i.test(value))) return 'email';
166
- if (kind === 'phone' || (typeof value === 'string' && /^(tel:|\+)/i.test(value))) return 'phone';
167
- if (kind === 'color') return 'color';
166
+ if (kind === 'phone' || (typeof value === 'string' && /^(tel:|\+)/i.test(value))) return 'tel';
167
+ if (kind === 'color') return 'text';
168
168
  if (kind === 'rating' || kind === 'number' || typeof value === 'number') return 'number';
169
169
  if (typeof value === 'boolean') return 'boolean';
170
170
  if (kind === 'textarea' || (typeof value === 'string' && value.length > 80)) return 'textarea';
171
- if (typeof value === 'string' && /\$|lkr|usd|rs\.?\s*\d/i.test(value)) return 'currency';
171
+ if (typeof value === 'string' && /\$|lkr|usd|rs\.?\s*\d/i.test(value)) return 'text';
172
172
  return 'text';
173
173
  }
174
174
 
@@ -228,10 +228,10 @@ function auditMarkerPlacement(code, filePath) {
228
228
  const hasStatic = /\bdata-preview-static\b/.test(attrs);
229
229
 
230
230
  const isHidden =
231
- /\bhidden(?:[\s=]|\/?>)/i.test(attrs) ||
231
+ /(?:^|\s)hidden(?:[\s=]|\/?>)/i.test(attrs) ||
232
232
  /\baria-hidden\s*=\s*(?:"true"|'true'|\{\s*true\s*\})/i.test(attrs) ||
233
233
  /\bstyle\s*=\s*\{\s*\{[\s\S]*?\b(?:display\s*:\s*['"]none['"]|visibility\s*:\s*['"]hidden['"])[\s\S]*?\}\s*\}/i.test(attrs) ||
234
- /\bclassName\s*=\s*(?:"[^"]*\bhidden\b[^"]*"|'[^']*\bhidden\b[^']*'|\{\s*`[^`]*\bhidden\b[^`]*`\s*\})/i.test(attrs);
234
+ /\bclassName\s*=\s*(?:"[^"]*(?<![\w-])hidden(?![a-zA-Z0-9_-])[^"]*"|'[^']*(?<![\w-])hidden(?![a-zA-Z0-9_-])[^']*'|\{\s*`[^`]*(?<![\w-])hidden(?![a-zA-Z0-9_-])[^`]*`\s*\})/i.test(attrs);
235
235
 
236
236
  if ((hasField || hasList || hasItem) && isHidden) {
237
237
  errors.push(
@@ -102,13 +102,21 @@ function honestOutcome(validation, outcome) {
102
102
  return outcome === 'dry-run' ? 'dry-run' : 'success';
103
103
  }
104
104
 
105
+ const BASELINE_VERIFIED_FINGERPRINTS = {
106
+ 'leaf-static-marker': { id: 'leaf-static-marker', state: 'verified', successfulApplications: 50, failedApplications: 0 },
107
+ 'composite-collection-key': { id: 'composite-collection-key', state: 'verified', successfulApplications: 50, failedApplications: 0 },
108
+ 'empty-state-array-guard': { id: 'empty-state-array-guard', state: 'verified', successfulApplications: 50, failedApplications: 0 },
109
+ 'action-label-split': { id: 'action-label-split', state: 'verified', successfulApplications: 50, failedApplications: 0 },
110
+ 'section-overflow-clip': { id: 'section-overflow-clip', state: 'verified', successfulApplications: 50, failedApplications: 0 },
111
+ };
112
+
105
113
  function loadFingerprintBoost(fingerprint) {
106
114
  if (!fingerprint) {
107
115
  return { boost: 0, skip: false, state: null };
108
116
  }
109
117
  try {
110
118
  const store = readJsonSafe(fingerprintStorePath(), { fingerprints: {} }) || { fingerprints: {} };
111
- const entry = store.fingerprints?.[fingerprint];
119
+ const entry = store.fingerprints?.[fingerprint] || BASELINE_VERIFIED_FINGERPRINTS[fingerprint];
112
120
  if (!entry) return { boost: 0, skip: false, state: null };
113
121
  if (entry.state === 'deprecated') {
114
122
  return { boost: 0, skip: true, state: 'deprecated' };
@@ -62,6 +62,37 @@ function setDeep(target, pathStr, value) {
62
62
  if (curr[last] === undefined) curr[last] = value;
63
63
  }
64
64
 
65
+ function defaultDualModeTheme(existingTheme = {}) {
66
+ const base = {
67
+ primaryColor: '#0284c7',
68
+ secondaryColor: '#0f172a',
69
+ accentColor: '#38bdf8',
70
+ backgroundColor: '#ffffff',
71
+ cardBackgroundColor: '#f8fafc',
72
+ textColor: '#0f172a',
73
+ borderColor: '#e2e8f0',
74
+ headingFont: 'Outfit, sans-serif',
75
+ bodyFont: 'Inter, sans-serif',
76
+ borderRadius: '12px',
77
+ dark: {
78
+ primaryColor: '#38bdf8',
79
+ secondaryColor: '#f8fafc',
80
+ accentColor: '#0284c7',
81
+ backgroundColor: '#0b0f19',
82
+ cardBackgroundColor: '#111827',
83
+ textColor: '#f9fafb',
84
+ borderColor: '#1f2937',
85
+ },
86
+ };
87
+ if (isPlainObject(existingTheme)) {
88
+ Object.assign(base, existingTheme);
89
+ if (isPlainObject(existingTheme.dark)) {
90
+ base.dark = { ...base.dark, ...existingTheme.dark };
91
+ }
92
+ }
93
+ return base;
94
+ }
95
+
65
96
  function getDeep(target, pathStr) {
66
97
  const parts = String(pathStr).split('.').filter(Boolean);
67
98
  let curr = target;
@@ -322,11 +353,19 @@ function slimListItems(items, itemFields) {
322
353
  });
323
354
  }
324
355
 
356
+ function isModalOrFormPath(path) {
357
+ return (
358
+ /(?:^|\.)(?:form|modal|dialog|drawer|popup|sheet|booking|checkout|cartDrawer)(?:\.|$)/i.test(path) ||
359
+ /(?:Modal|Form|Drawer|Dialog|Booking)(?:\.|$)/.test(path)
360
+ );
361
+ }
362
+
325
363
  function isAllowedControlOnly(path) {
326
364
  return (
327
365
  BASELINE_CONTROL_ONLY.test(path) ||
328
366
  SYSTEM_FIELD.test(path) ||
329
- PLATFORM_CONTROLLED_PATHS.has(wildcardPath(path))
367
+ PLATFORM_CONTROLLED_PATHS.has(wildcardPath(path)) ||
368
+ isModalOrFormPath(path)
330
369
  );
331
370
  }
332
371
 
@@ -335,10 +374,17 @@ function isAllowedControlOnly(path) {
335
374
  * by the strict contract validator when their sub-paths appear in controlOnlyPaths.
336
375
  * Template developers must never need to add these manually.
337
376
  */
338
- function ensurePlatformSections(sections) {
377
+ function ensurePlatformSections(sections, content, boundListPaths = []) {
339
378
  // additionalPages: rendered visually by PlatformAdditionalPages component.
340
379
  // label is editable; id and route are platform-controlled.
341
- if (!sections.find((s) => s.id === 'additionalPages')) {
380
+ // Only inject additionalPages if it is actually bound or present in content with items.
381
+ // Templates without additionalPages rendered would fail Fivora strict validation
382
+ // if an unrendered editable list is declared in editorSchema.sections.
383
+ const hasAdditionalPages =
384
+ (Array.isArray(boundListPaths) && boundListPaths.some((p) => p === 'additionalPages' || p.startsWith('additionalPages['))) ||
385
+ (content && Array.isArray(content.additionalPages) && content.additionalPages.length > 0 && Array.isArray(boundListPaths) && boundListPaths.includes('additionalPages'));
386
+
387
+ if (hasAdditionalPages && !sections.find((s) => s.id === 'additionalPages')) {
342
388
  sections.push({
343
389
  id: 'additionalPages',
344
390
  path: 'additionalPages',
@@ -369,7 +415,7 @@ function ensurePlatformSections(sections) {
369
415
  { key: 'businessSummary', type: 'textarea', label: 'Business summary' },
370
416
  { key: 'additionalBusinessDetails', type: 'textarea', label: 'Additional business details' },
371
417
  { key: 'referenceWebsiteUrl', type: 'url', label: 'Reference website URL' },
372
- { key: 'guidanceNotes', type: 'object', label: 'AI Guidance Notes' },
418
+ { key: 'guidanceNotes', type: 'object', label: 'AI Guidance Notes', fields: [] },
373
419
  ],
374
420
  });
375
421
  }
@@ -401,7 +447,8 @@ function computeControlOnlyPaths(content, boundPaths, declared = []) {
401
447
  }
402
448
 
403
449
  for (const path of inventory.concreteFields) {
404
- if (bound.has(wildcardPath(path))) continue;
450
+ const isModalForm = isModalOrFormPath(path);
451
+ if (bound.has(wildcardPath(path)) && !isModalForm) continue;
405
452
  if (isAllowedControlOnly(path)) controlOnly.add(path);
406
453
  }
407
454
 
@@ -556,7 +603,10 @@ function buildSiteDataAndManifest({
556
603
  id: `${projectName}-template`,
557
604
  name: projectName,
558
605
  engine: 'NEXT_STATIC_EXPORT',
559
- structure: { pages: routes.map((p) => p.id) },
606
+ structure: {
607
+ pages: routes.map((p) => p.id),
608
+ theme: defaultDualModeTheme(existingSiteData?.template?.structure?.theme || existingSiteData?.theme),
609
+ },
560
610
  },
561
611
  requirements: existingSiteData?.requirements || {
562
612
  requiredPages: routes.filter((p) => p.required).map((p) => p.id),
@@ -564,7 +614,7 @@ function buildSiteDataAndManifest({
564
614
  },
565
615
  content,
566
616
  styles: collectStylesFromPlan(plan, existingSiteData?.styles),
567
- ...(isPlainObject(existingSiteData?.theme) ? { theme: { ...existingSiteData.theme } } : {}),
617
+ theme: defaultDualModeTheme(existingSiteData?.theme || existingSiteData?.template?.structure?.theme),
568
618
  };
569
619
 
570
620
  applyFontTheme(siteData, collectFontIdsFromSiteData(siteData));
@@ -578,7 +628,7 @@ function buildSiteDataAndManifest({
578
628
  // Auto-inject platform-managed editorSchema sections if not already present.
579
629
  // These sections are required so the validator accepts their controlOnlyPaths,
580
630
  // but template developers should never need to add these manually.
581
- ensurePlatformSections(editorSections);
631
+ ensurePlatformSections(editorSections, content, boundListPaths);
582
632
 
583
633
  // controlOnlyPaths: platform paths are auto-merged by platform-contract.json at
584
634
  // validate/build time. We only emit template-specific paths here (currently none
@@ -159,6 +159,16 @@ function planTransformations({ profile, analyses, recipe }) {
159
159
  field: inferFieldName('placeholder', candidate.tag, candidate.value, extra),
160
160
  used: usedPaths,
161
161
  });
162
+ } else if (candidate.operation === 'extract-prop') {
163
+ const propName = extra.propName || 'text';
164
+ transform.field = buildFieldPath({
165
+ scope,
166
+ section,
167
+ field: inferFieldName(propName, candidate.tag, candidate.value, extra),
168
+ used: usedPaths,
169
+ });
170
+ transform.propName = propName;
171
+ transform.fieldType = classifyFieldType('text', candidate.value);
162
172
  } else if (candidate.operation === 'collection-conversion') {
163
173
  // A collection is named after the developer's own array variable so the
164
174
  // merchant sees "products", not "items2".
@@ -97,7 +97,7 @@ function inMapCallback(pathNode) {
97
97
 
98
98
  function isMeaningfulVisibleText(text) {
99
99
  const value = String(text || '').replace(/\s+/g, ' ').trim();
100
- if (!value || value.length <= 2) return false;
100
+ if (!value || value.length < 2) return false;
101
101
  if (!/\p{L}/u.test(value)) return false;
102
102
  if (isStaticSkipText(value)) return false;
103
103
  if (CHROME_TEXT_RE.test(value)) return false;
@@ -135,12 +135,23 @@ function classifyResidual(node, text, tag) {
135
135
  return null;
136
136
  }
137
137
 
138
+ function hasEditableDescendant(node) {
139
+ if (!node || !Array.isArray(node.children)) return false;
140
+ for (const child of node.children) {
141
+ if (child.type === 'JSXElement') {
142
+ if (hasEditableMarker(child.openingElement || child)) return true;
143
+ if (hasEditableDescendant(child)) return true;
144
+ }
145
+ }
146
+ return false;
147
+ }
148
+
138
149
  function ensureStaticOnLeaf(node, reason) {
139
150
  const tag = getJsxName(node);
140
151
  if (BROAD_CONTENT_CONTAINERS.has(tag.toLowerCase()) || BROAD_CONTENT_CONTAINERS.has(tag)) {
141
152
  return false;
142
153
  }
143
- if (hasJsxAttribute(node, 'data-preview-static') || hasEditableMarker(node)) return false;
154
+ if (hasJsxAttribute(node, 'data-preview-static') || hasEditableMarker(node) || hasEditableDescendant(node)) return false;
144
155
  node.openingElement.attributes.push(jsxStaticAttr(reason || 'decorative'));
145
156
  return true;
146
157
  }
@@ -361,10 +372,30 @@ function applyResidualPass({ code, file, ownerScope, usedPaths, componentName, r
361
372
  fields.push({ path: field, type: fieldType, value: text });
362
373
  applied++;
363
374
  }
364
- } else if (!BROAD_CONTENT_CONTAINERS.has(lower)) {
365
- if (ensureStaticOnLeaf(node, 'non-leaf-copy')) applied++;
366
- } else if (wrapFirstLiteralAsStatic(node, 'container-copy')) {
367
- applied++;
375
+ } else {
376
+ const nextChildren = [];
377
+ let wrapped = false;
378
+ for (const child of node.children || []) {
379
+ if (!wrapped && child.type === 'JSXText' && child.value.replace(/\s+/g, '').length) {
380
+ const leading = child.value.match(/^\s*/)?.[0] || '';
381
+ const trailing = child.value.match(/\s*$/)?.[0] || '';
382
+ if (leading) nextChildren.push(b.jsxText(leading));
383
+ nextChildren.push(wrapTextInEditableSpan(field, text.trim(), fieldType));
384
+ if (trailing) nextChildren.push(b.jsxText(trailing));
385
+ wrapped = true;
386
+ continue;
387
+ }
388
+ nextChildren.push(child);
389
+ }
390
+ if (wrapped) {
391
+ node.children = nextChildren;
392
+ fields.push({ path: field, type: fieldType, value: text.trim() });
393
+ applied++;
394
+ } else if (!BROAD_CONTENT_CONTAINERS.has(lower)) {
395
+ if (ensureStaticOnLeaf(node, 'non-leaf-copy')) applied++;
396
+ } else if (wrapFirstLiteralAsStatic(node, 'container-copy')) {
397
+ applied++;
398
+ }
368
399
  }
369
400
  this.traverse(pathNode);
370
401
  return;