@deneb-ui/cli 2.0.31 → 2.0.33

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.31",
3
+ "version": "2.0.33",
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.31",
52
+ "@deneb-ui/core": "^2.0.33",
53
53
  "adm-zip": "^0.6.0",
54
54
  "recast": "^0.23.11"
55
55
  },
@@ -10,11 +10,57 @@ const { scanProject, buildDependencyGraph } = require('../scanner.cjs');
10
10
  const { analyzeFile } = require('../semantic.cjs');
11
11
  const { planTransformations } = require('../planner.cjs');
12
12
  const { applyFilePlan } = require('../transformer.cjs');
13
- const { buildFieldPath, inferFieldName } = require('../field-paths.cjs');
13
+ const { buildFieldPath, inferFieldName, isListActionCtaKey } = require('../field-paths.cjs');
14
+ const { enrichSchemasFromContent } = require('../manifest.cjs');
14
15
  const { runDenebArc } = require('../index.cjs');
15
16
  const { parseSource } = require('../ast.cjs');
16
17
  const { loadFingerprintBoost } = require('../learning.cjs');
17
18
 
19
+ test('field-paths recognizes list action CTA keys', () => {
20
+ assert.equal(isListActionCtaKey('preOrderCta'), true);
21
+ assert.equal(isListActionCtaKey('addToTrayCta'), true);
22
+ assert.equal(isListActionCtaKey('features'), false);
23
+ });
24
+
25
+ test('manifest enrichSchemasFromContent registers list CTA and paired directions fields', () => {
26
+ const sections = [
27
+ {
28
+ id: 'contact',
29
+ path: 'contact',
30
+ type: 'object',
31
+ label: 'Contact',
32
+ fields: [],
33
+ },
34
+ {
35
+ id: 'home',
36
+ path: 'home',
37
+ type: 'object',
38
+ label: 'Home',
39
+ fields: [],
40
+ },
41
+ ];
42
+ enrichSchemasFromContent(
43
+ {
44
+ contact: {
45
+ directionsLabel: 'Get Directions',
46
+ directionsUrl: 'https://maps.example',
47
+ },
48
+ home: {
49
+ demoPreOrderCta: [{ buttonLabel: 'Order', buttonUrl: 'https://wa.me/123' }],
50
+ },
51
+ },
52
+ sections
53
+ );
54
+ const contactFields = sections.find((s) => s.id === 'contact').fields;
55
+ assert.ok(contactFields.some((f) => f.key === 'directionsUrl' && f.type === 'url'));
56
+ assert.ok(contactFields.some((f) => f.key === 'directionsLabel'));
57
+ const homeFields = sections.find((s) => s.id === 'home').fields;
58
+ const list = homeFields.find((f) => f.key === 'demoPreOrderCta');
59
+ assert.ok(list && list.type === 'list');
60
+ assert.ok(list.fields.some((f) => f.key === 'buttonLabel'));
61
+ assert.ok(list.fields.some((f) => f.key === 'buttonUrl' && f.type === 'url'));
62
+ });
63
+
18
64
  test('unseen fingerprints do not change planner confidence', () => {
19
65
  const hint = loadFingerprintBoost(null);
20
66
  assert.equal(hint.boost, 0);
@@ -109,6 +155,8 @@ test('AST transformer preserves className and uses nullish fallbacks', () => {
109
155
  assert.match(result.code, /data-preview-field-path=/);
110
156
  assert.match(result.code, /\?\?/);
111
157
  assert.match(result.code, /<span[\s\S]*data-preview-field-path="/);
158
+ assert.match(result.code, /<span[^>]*hidden[^>]*data-preview-field-path="/);
159
+ assert.match(result.code, /data-preview-static="action-link"/);
112
160
  assert.doesNotMatch(result.code, /'use client'/);
113
161
  assert.match(result.code, /site-data\.json|@\/data\/site-data\.json/);
114
162
  assert.match(result.code, /data-preview-style-target=/);
@@ -519,3 +567,62 @@ test('conflicting data-preview-static is stripped when element has editable mark
519
567
  const placementErrors = errors.filter((e) => e.includes('cannot share an element with data-preview-static'));
520
568
  assert.equal(placementErrors.length, 0);
521
569
  });
570
+
571
+ test('sanitizeDuplicateBindings keeps a single useSiteData import', () => {
572
+ const { parseSource, printSource, sanitizeDuplicateBindings } = require('../ast.cjs');
573
+ const code = `'use client';
574
+ import { useSiteData, contentText } from '@/lib/siteDataContext';
575
+ import { useSiteData, contentObject } from '@deneb-ui/ui';
576
+ export function Shop() {
577
+ const siteData = useSiteData();
578
+ return <div>{contentText(contentObject(siteData).title)}</div>;
579
+ }
580
+ `;
581
+ const ast = parseSource(code, 'shop.tsx');
582
+ sanitizeDuplicateBindings(ast);
583
+ const out = printSource(ast, code);
584
+ const importUses = [...out.matchAll(/import\s*\{([^}]+)\}\s*from/g)].flatMap((m) =>
585
+ m[1].split(',').map((s) => s.trim()).filter((s) => s === 'useSiteData')
586
+ );
587
+ assert.equal(importUses.length, 1);
588
+ assert.match(out, /siteDataContext/);
589
+ assert.doesNotMatch(out, /import\s*\{[^}]*useSiteData[^}]*\}\s*from\s*['"]@deneb-ui\/ui['"]/);
590
+ });
591
+
592
+ test('recursive SiteDataProvider wrappers are flattened to a package re-export', () => {
593
+ const { rewriteRecursiveSiteDataContext } = require('../transformer.cjs');
594
+ const code = `'use client';
595
+ import { SiteDataProvider as BaseSiteDataProvider } from '@deneb-ui/ui';
596
+ import initialSiteData from '@/data/site-data.json';
597
+ export function SiteDataProvider({ children, ...props }) {
598
+ return (
599
+ <BaseSiteDataProvider initialSiteData={initialSiteData} {...props}>
600
+ {children}
601
+ </BaseSiteDataProvider>
602
+ );
603
+ }
604
+ `;
605
+ const out = rewriteRecursiveSiteDataContext(code);
606
+ assert.equal(out.updated, true);
607
+ assert.match(out.code, /export \{/);
608
+ assert.match(out.code, /SiteDataProvider/);
609
+ assert.match(out.code, /from '@deneb-ui\/ui'/);
610
+ assert.doesNotMatch(out.code, /export function SiteDataProvider/);
611
+ assert.doesNotMatch(out.code, /BaseSiteDataProvider/);
612
+ });
613
+
614
+ test('import plus export of useSiteData is rewritten to export-from', () => {
615
+ const { parseSource, printSource, sanitizeDuplicateBindings } = require('../ast.cjs');
616
+ const code = `'use client';
617
+ import { SiteDataProvider as BaseSiteDataProvider, useSiteData, contentText } from '@deneb-ui/ui';
618
+ export function SiteDataProvider({ children }) {
619
+ return <BaseSiteDataProvider>{children}</BaseSiteDataProvider>;
620
+ }
621
+ export { useSiteData, contentText };
622
+ `;
623
+ const ast = parseSource(code, 'siteDataContext.tsx');
624
+ sanitizeDuplicateBindings(ast);
625
+ const out = printSource(ast, code);
626
+ assert.match(out, /export\s*\{[^}]*useSiteData[^}]*\}\s*from\s*['"]@deneb-ui\/ui['"]/);
627
+ assert.doesNotMatch(out, /import\s*\{[^}]*useSiteData/);
628
+ });
package/src/arc/ast.cjs CHANGED
@@ -264,11 +264,144 @@ function hasDirective(ast, value) {
264
264
  return false;
265
265
  }
266
266
 
267
+ function localNameOfSpecifier(spec) {
268
+ return spec?.local?.name || spec?.imported?.name || spec?.exported?.name || null;
269
+ }
270
+
271
+ function dedupeImportSpecifiers(ast) {
272
+ const program = ast.program || ast;
273
+ const seenLocals = new Set();
274
+ for (const node of program.body || []) {
275
+ if (node.type !== 'ImportDeclaration' || !Array.isArray(node.specifiers)) continue;
276
+ node.specifiers = node.specifiers.filter((spec) => {
277
+ const local = localNameOfSpecifier(spec);
278
+ if (!local) return true;
279
+ if (seenLocals.has(local)) return false;
280
+ seenLocals.add(local);
281
+ return true;
282
+ });
283
+ }
284
+ }
285
+
286
+ function preferLocalSiteDataImport(ast) {
287
+ const program = ast.program || ast;
288
+ const body = program.body || [];
289
+ const localNames = new Set();
290
+ for (const node of body) {
291
+ if (node.type !== 'ImportDeclaration') continue;
292
+ const src = node.source && node.source.value;
293
+ if (typeof src !== 'string' || !/siteDataContext/.test(src)) continue;
294
+ for (const spec of node.specifiers || []) {
295
+ const local = localNameOfSpecifier(spec);
296
+ if (local) localNames.add(local);
297
+ }
298
+ }
299
+ if (localNames.size === 0) return;
300
+ for (const node of body) {
301
+ if (node.type !== 'ImportDeclaration') continue;
302
+ const src = node.source && node.source.value;
303
+ if (src !== '@deneb-ui/ui' && src !== 'deneb-ui' && src !== '@fivora/editable-components') continue;
304
+ node.specifiers = (node.specifiers || []).filter((spec) => !localNames.has(localNameOfSpecifier(spec)));
305
+ }
306
+ }
307
+
308
+ function dropImportedNameIfLocallyDeclared(ast) {
309
+ const program = ast.program || ast;
310
+ const declared = new Set();
311
+ for (const node of program.body || []) {
312
+ if (node.type === 'FunctionDeclaration' && node.id && node.id.name) {
313
+ declared.add(node.id.name);
314
+ }
315
+ if (node.type === 'ExportNamedDeclaration' && node.declaration) {
316
+ const decl = node.declaration;
317
+ if (decl.type === 'FunctionDeclaration' && decl.id && decl.id.name) {
318
+ declared.add(decl.id.name);
319
+ }
320
+ if (decl.type === 'VariableDeclaration') {
321
+ for (const d of decl.declarations || []) {
322
+ if (d.id && d.id.type === 'Identifier') declared.add(d.id.name);
323
+ }
324
+ }
325
+ }
326
+ }
327
+ if (declared.size === 0) return;
328
+ for (const node of program.body || []) {
329
+ if (node.type !== 'ImportDeclaration') continue;
330
+ node.specifiers = (node.specifiers || []).filter((spec) => !declared.has(localNameOfSpecifier(spec)));
331
+ }
332
+ }
333
+
334
+ function rewriteImportedReexports(ast) {
335
+ const program = ast.program || ast;
336
+ const importSourceByLocal = new Map();
337
+ for (const node of program.body || []) {
338
+ if (node.type !== 'ImportDeclaration') continue;
339
+ const src = node.source && node.source.value;
340
+ for (const spec of node.specifiers || []) {
341
+ const local = localNameOfSpecifier(spec);
342
+ if (local && src) importSourceByLocal.set(local, src);
343
+ }
344
+ }
345
+
346
+ const exportDecls = (program.body || []).filter(
347
+ (node) => node.type === 'ExportNamedDeclaration' && !node.source && node.specifiers && node.specifiers.length
348
+ );
349
+ for (const node of exportDecls) {
350
+ const groups = new Map();
351
+ const keep = [];
352
+ for (const spec of node.specifiers) {
353
+ const local = spec.local?.name;
354
+ const src = local && importSourceByLocal.get(local);
355
+ if (!src) {
356
+ keep.push(spec);
357
+ continue;
358
+ }
359
+ if (!groups.has(src)) groups.set(src, []);
360
+ groups.get(src).push(spec);
361
+ }
362
+ if (!groups.size) continue;
363
+ node.specifiers = keep;
364
+ let insertAt = program.body.indexOf(node) + 1;
365
+ for (const [src, specs] of groups.entries()) {
366
+ program.body.splice(insertAt, 0, b.exportNamedDeclaration(null, specs, b.stringLiteral(src)));
367
+ insertAt++;
368
+ for (const spec of specs) {
369
+ const local = spec.local?.name;
370
+ for (const imp of program.body) {
371
+ if (imp.type !== 'ImportDeclaration') continue;
372
+ if (!imp.specifiers) continue;
373
+ imp.specifiers = imp.specifiers.filter((s) => localNameOfSpecifier(s) !== local);
374
+ }
375
+ }
376
+ }
377
+ }
378
+ }
379
+
380
+ function stripEmptyImportAndExportDecls(ast) {
381
+ const program = ast.program || ast;
382
+ program.body = (program.body || []).filter((node) => {
383
+ if (node.type === 'ImportDeclaration') {
384
+ return (node.specifiers || []).length > 0;
385
+ }
386
+ if (node.type === 'ExportNamedDeclaration' && !node.declaration && !node.source) {
387
+ return (node.specifiers || []).length > 0;
388
+ }
389
+ return true;
390
+ });
391
+ }
392
+
393
+ function sanitizeDuplicateBindings(ast) {
394
+ dropImportedNameIfLocallyDeclared(ast);
395
+ preferLocalSiteDataImport(ast);
396
+ rewriteImportedReexports(ast);
397
+ dedupeImportSpecifiers(ast);
398
+ stripEmptyImportAndExportDecls(ast);
399
+ }
400
+
267
401
  function ensureImport(ast, source, names) {
268
402
  const program = ast.program || ast;
269
403
  const body = program.body || [];
270
404
 
271
- // Deduplicate against any import in the entire module so we never import the same identifier twice
272
405
  const alreadyImported = new Set();
273
406
  for (const node of body) {
274
407
  if (node.type === 'ImportDeclaration' && Array.isArray(node.specifiers)) {
@@ -277,6 +410,10 @@ function ensureImport(ast, source, names) {
277
410
  if (local) alreadyImported.add(local);
278
411
  }
279
412
  }
413
+ if (node.type === 'FunctionDeclaration' && node.id?.name) alreadyImported.add(node.id.name);
414
+ if (node.type === 'ExportNamedDeclaration' && node.declaration?.type === 'FunctionDeclaration' && node.declaration.id?.name) {
415
+ alreadyImported.add(node.declaration.id.name);
416
+ }
280
417
  }
281
418
 
282
419
  const namesToImport = names.filter((name) => !alreadyImported.has(name));
@@ -340,6 +477,7 @@ module.exports = {
340
477
  hasDirective,
341
478
  ensureImport,
342
479
  ensureDefaultImport,
480
+ sanitizeDuplicateBindings,
343
481
  codeHasIdentifier,
344
482
  t,
345
483
  b,
@@ -141,6 +141,14 @@ function humanLabel(fieldPath) {
141
141
  .trim();
142
142
  }
143
143
 
144
+ function isListActionCtaKey(key) {
145
+ return /Cta$/i.test(String(key || ''));
146
+ }
147
+
148
+ function listActionCtaItemFieldNames() {
149
+ return ['buttonLabel', 'buttonUrl'];
150
+ }
151
+
144
152
  function classifyFieldType(kind, value) {
145
153
  if (kind === 'image') return 'image';
146
154
  if (kind === 'url') return 'url';
@@ -162,4 +170,6 @@ module.exports = {
162
170
  buildFieldPath,
163
171
  humanLabel,
164
172
  classifyFieldType,
173
+ isListActionCtaKey,
174
+ listActionCtaItemFieldNames,
165
175
  };
package/src/arc/index.cjs CHANGED
@@ -17,7 +17,7 @@ const { walkFiles, isJsxFile, rel, copyFilePreserve, writeJson, readJsonSafe, fi
17
17
  const { scanProject, buildDependencyGraph, inferOwnerScope } = require('./scanner.cjs');
18
18
  const { analyzeFile, collectDesignSnapshot } = require('./semantic.cjs');
19
19
  const { planTransformations } = require('./planner.cjs');
20
- const { applyFilePlan, instrumentLayoutSource, instrumentPageKey, resolveSiteDataSpecifier, ensureJsonModule, sanitizeContradictoryMarkersInSource } = require('./transformer.cjs');
20
+ const { applyFilePlan, instrumentLayoutSource, instrumentPageKey, resolveSiteDataSpecifier, resolveSiteDataRuntimeSpecifier, rewriteRecursiveSiteDataContext, ensureJsonModule, sanitizeContradictoryMarkersInSource } = require('./transformer.cjs');
21
21
  const { parseSource } = require('./ast.cjs');
22
22
  const { buildSiteDataAndManifest, writeDataBank, loadExistingData, countSchemaFields } = require('./manifest.cjs');
23
23
  const { validateAstFiles, validateContracts, designPreservationScore, coverageMetrics } = require('./validator.cjs');
@@ -306,8 +306,9 @@ function runDenebArc(projectDir, projectName, options = {}) {
306
306
  backupFile(projectDir, backupDir, layoutFile);
307
307
  const layoutRel = rel(projectDir, layoutFile);
308
308
  const siteDataImport = resolveSiteDataSpecifier(profile, layoutRel);
309
+ const providerImport = resolveSiteDataRuntimeSpecifier(profile);
309
310
  const original = fs.readFileSync(layoutFile, 'utf8');
310
- const instrumented = instrumentLayoutSource(original, siteDataImport);
311
+ const instrumented = instrumentLayoutSource(original, siteDataImport, providerImport);
311
312
  if (instrumented.updated && instrumented.code !== original) {
312
313
  fs.writeFileSync(layoutFile, instrumented.code, 'utf8');
313
314
  changedFiles.push(layoutRel);
@@ -315,6 +316,23 @@ function runDenebArc(projectDir, projectName, options = {}) {
315
316
  }
316
317
  }
317
318
 
319
+ const contextCandidates = [
320
+ path.join(projectDir, 'src', 'lib', 'siteDataContext.tsx'),
321
+ path.join(projectDir, 'src', 'lib', 'siteDataContext.ts'),
322
+ path.join(projectDir, 'lib', 'siteDataContext.tsx'),
323
+ path.join(projectDir, 'lib', 'siteDataContext.ts'),
324
+ ];
325
+ for (const abs of contextCandidates) {
326
+ if (!fs.existsSync(abs)) continue;
327
+ backupFile(projectDir, backupDir, abs);
328
+ const original = fs.readFileSync(abs, 'utf8');
329
+ const rewritten = rewriteRecursiveSiteDataContext(original);
330
+ if (rewritten.updated && rewritten.code !== original) {
331
+ fs.writeFileSync(abs, rewritten.code, 'utf8');
332
+ changedFiles.push(rel(projectDir, abs));
333
+ }
334
+ }
335
+
318
336
  for (const filePlan of plan.files) {
319
337
  if (!filePlan.transformations.length || filePlan.skippedFile) continue;
320
338
  const abs = path.join(projectDir, filePlan.file);
@@ -59,7 +59,7 @@ function upsertSchemaField(sections, fieldPath, fieldType, label, required = fal
59
59
  if (!existing) {
60
60
  fields.push({
61
61
  key,
62
- type: fieldType === 'url' ? 'text' : fieldType === 'textarea' ? 'textarea' : fieldType,
62
+ type: fieldType === 'url' ? 'url' : fieldType === 'textarea' ? 'textarea' : fieldType === 'phone' ? 'tel' : fieldType,
63
63
  label: label || humanLabel(key),
64
64
  ...(required ? { required: true } : {}),
65
65
  });
@@ -119,12 +119,77 @@ function upsertSchemaList(sections, listPath, itemFields, items) {
119
119
  maxItems: Math.max((items || []).length, 12),
120
120
  fields: (itemFields || []).map((field) => ({
121
121
  key: field.key,
122
- type: field.type === 'url' ? 'url' : field.type === 'image' ? 'image' : 'text',
122
+ type:
123
+ field.type === 'url'
124
+ ? 'url'
125
+ : field.type === 'image'
126
+ ? 'image'
127
+ : field.type === 'textarea'
128
+ ? 'textarea'
129
+ : field.type === 'tel' || field.type === 'phone'
130
+ ? 'tel'
131
+ : field.type === 'email'
132
+ ? 'email'
133
+ : field.type || 'text',
123
134
  label: humanLabel(field.key),
124
135
  })),
125
136
  });
126
137
  }
127
138
 
139
+ const LIST_ACTION_CTA_ITEM_FIELDS = [
140
+ { key: 'buttonLabel', type: 'text' },
141
+ { key: 'buttonUrl', type: 'url' },
142
+ ];
143
+
144
+ function isListActionCtaKey(key) {
145
+ return /Cta$/i.test(String(key || ''));
146
+ }
147
+
148
+ /**
149
+ * Walks merged site-data content and registers schemas ARC learns from merchant patterns:
150
+ * list CTAs (`*Cta` with buttonLabel/buttonUrl) and paired *Label/*Url siblings.
151
+ */
152
+ function enrichSchemasFromContent(content, sections) {
153
+ if (!content || typeof content !== 'object') return;
154
+
155
+ function walk(node, prefix) {
156
+ if (!node || typeof node !== 'object') return;
157
+ if (Array.isArray(node)) {
158
+ const listKey = prefix.split('.').pop() || '';
159
+ if (isListActionCtaKey(listKey)) {
160
+ const sample = node[0];
161
+ if (sample && typeof sample === 'object' && ('buttonLabel' in sample || 'buttonUrl' in sample)) {
162
+ upsertSchemaList(sections, prefix, LIST_ACTION_CTA_ITEM_FIELDS, node);
163
+ }
164
+ }
165
+ node.forEach((item, idx) => {
166
+ if (item && typeof item === 'object') walk(item, `${prefix}[${idx}]`);
167
+ });
168
+ return;
169
+ }
170
+
171
+ for (const [key, value] of Object.entries(node)) {
172
+ const nextPath = prefix ? `${prefix}.${key}` : key;
173
+ if (Array.isArray(value) && isListActionCtaKey(key)) {
174
+ const sample = value[0];
175
+ if (sample && typeof sample === 'object' && ('buttonLabel' in sample || 'buttonUrl' in sample)) {
176
+ upsertSchemaList(sections, nextPath, LIST_ACTION_CTA_ITEM_FIELDS, value);
177
+ }
178
+ } else if (typeof value === 'string' && /Url$/i.test(key)) {
179
+ upsertSchemaField(sections, nextPath, 'url', humanLabel(key));
180
+ const labelKey = key.replace(/Url$/i, 'Label');
181
+ if (Object.prototype.hasOwnProperty.call(node, labelKey)) {
182
+ upsertSchemaField(sections, `${prefix}.${labelKey}`, 'text', humanLabel(labelKey));
183
+ }
184
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
185
+ walk(value, nextPath);
186
+ }
187
+ }
188
+ }
189
+
190
+ walk(content, '');
191
+ }
192
+
128
193
  function collectFieldsFromPlan(plan) {
129
194
  const fields = [];
130
195
  for (const file of plan.files || []) {
@@ -362,6 +427,7 @@ function buildSiteDataAndManifest({
362
427
  siteData.template.structure.pages = routes.map((p) => p.id);
363
428
 
364
429
  assignSectionPageKeys(editorSections, routes, markerRoutes);
430
+ enrichSchemasFromContent(content, editorSections);
365
431
 
366
432
  const controlOnlyPaths = computeControlOnlyPaths(
367
433
  content,
@@ -435,4 +501,6 @@ module.exports = {
435
501
  computeControlOnlyPaths,
436
502
  assignSectionPageKeys,
437
503
  upsertSchemaList,
504
+ enrichSchemasFromContent,
505
+ isListActionCtaKey,
438
506
  };
@@ -2,6 +2,7 @@
2
2
 
3
3
  const recast = require('recast');
4
4
  const path = require('path');
5
+ const fs = require('fs');
5
6
  const {
6
7
  parseSource,
7
8
  printSource,
@@ -12,6 +13,7 @@ const {
12
13
  hasDirective,
13
14
  ensureImport,
14
15
  ensureDefaultImport,
16
+ sanitizeDuplicateBindings,
15
17
  siteDataBinding,
16
18
  siteDataListBinding,
17
19
  jsxPreviewAttr,
@@ -124,13 +126,42 @@ function splitActionChildren(node, labelField, labelFallback) {
124
126
  node.children = nextChildren;
125
127
  }
126
128
 
129
+ function wrapHiddenUrlSibling(pathNode, urlField, fallback) {
130
+ const urlParts = urlField.split('.');
131
+ const hiddenUrl = b.jsxElement(
132
+ b.jsxOpeningElement(
133
+ b.jsxIdentifier('span'),
134
+ [
135
+ b.jsxAttribute(b.jsxIdentifier('hidden')),
136
+ b.jsxAttribute(b.jsxIdentifier('aria-hidden'), b.stringLiteral('true')),
137
+ jsxPreviewAttr(urlField),
138
+ ],
139
+ false
140
+ ),
141
+ b.jsxClosingElement(b.jsxIdentifier('span')),
142
+ [b.jsxExpressionContainer(siteDataBinding(urlParts, fallback, 'url'))],
143
+ false
144
+ );
145
+ pathNode.insertAfter(hiddenUrl);
146
+ }
147
+
127
148
  function applyTransformToElement(pathNode, transform) {
128
149
  const node = pathNode.node;
129
150
  if (transform.operation === 'split-action-contract') {
130
151
  const urlParts = transform.urlField.split('.');
131
152
  replaceAttrValue(node, 'href', siteDataBinding(urlParts, transform.fallback, 'url'));
132
- ensurePreviewPath(node, transform.urlField);
153
+ if (!hasJsxAttribute(node, 'data-preview-static')) {
154
+ node.openingElement.attributes.push(
155
+ b.jsxAttribute(b.jsxIdentifier('data-preview-static'), b.stringLiteral('action-link'))
156
+ );
157
+ }
158
+ if (hasJsxAttribute(node, 'data-preview-field-path')) {
159
+ node.openingElement.attributes = node.openingElement.attributes.filter(
160
+ (attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'data-preview-field-path')
161
+ );
162
+ }
133
163
  splitActionChildren(node, transform.labelField, transform.labelFallback || '');
164
+ wrapHiddenUrlSibling(pathNode, transform.urlField, transform.fallback);
134
165
  return;
135
166
  }
136
167
  if (transform.operation === 'extract-url') {
@@ -370,6 +401,43 @@ function bindArrayDeclaration(ast, mapCallPath, listPath) {
370
401
  return bound;
371
402
  }
372
403
 
404
+ function fileAlreadyUsesSiteDataHook(ast) {
405
+ let found = false;
406
+ recast.types.visit(ast, {
407
+ visitCallExpression(pathNode) {
408
+ const callee = pathNode.node.callee;
409
+ if (callee && callee.type === 'Identifier' && callee.name === 'useSiteData') {
410
+ found = true;
411
+ return false;
412
+ }
413
+ this.traverse(pathNode);
414
+ },
415
+ });
416
+ return found;
417
+ }
418
+
419
+ function resolveSiteDataRuntimeSpecifier(profile) {
420
+ const root = profile && profile.root;
421
+ if (!root) return '@deneb-ui/ui';
422
+ const candidates = [
423
+ ['src/lib/siteDataContext.tsx', '@/lib/siteDataContext'],
424
+ ['src/lib/siteDataContext.ts', '@/lib/siteDataContext'],
425
+ ['lib/siteDataContext.tsx', '@/lib/siteDataContext'],
426
+ ['lib/siteDataContext.ts', '@/lib/siteDataContext'],
427
+ ];
428
+ const hasAt = Object.keys(profile.aliasMap || {}).some((k) => k === '@/*' || k.startsWith('@/'));
429
+ for (const [relative, alias] of candidates) {
430
+ if (!fs.existsSync(path.join(root, relative))) continue;
431
+ if (hasAt) return alias;
432
+ const fromAbs = path.join(root, 'src/components/placeholder.tsx');
433
+ const toAbs = path.join(root, relative.replace(/\.tsx?$/, ''));
434
+ let relSpec = path.relative(path.dirname(fromAbs), toAbs).replace(/\\/g, '/');
435
+ if (!relSpec.startsWith('.')) relSpec = './' + relSpec;
436
+ return relSpec;
437
+ }
438
+ return '@deneb-ui/ui';
439
+ }
440
+
373
441
  function injectSiteDataHook(ast) {
374
442
  const program = ast.program || ast;
375
443
  let injected = false;
@@ -508,11 +576,15 @@ function applyFilePlan(filePlan, profile) {
508
576
 
509
577
  const siteDataImport = resolveSiteDataSpecifier(profile, filePlan.file);
510
578
  if (isClient) {
511
- ensureImport(ast, '@deneb-ui/ui', ['useSiteData']);
512
- injectSiteDataHook(ast);
579
+ const runtimeSpecifier = resolveSiteDataRuntimeSpecifier(profile);
580
+ if (!fileAlreadyUsesSiteDataHook(ast)) {
581
+ injectSiteDataHook(ast);
582
+ }
583
+ ensureImport(ast, runtimeSpecifier, ['useSiteData']);
513
584
  } else {
514
585
  ensureDefaultImport(ast, siteDataImport, 'siteData');
515
586
  }
587
+ sanitizeDuplicateBindings(ast);
516
588
 
517
589
  // Sanitize any conflicting data-preview-static on elements with editable markers
518
590
  sanitizeContradictoryMarkers(ast);
@@ -610,57 +682,91 @@ function ensureHtmlBodyHydration(ast) {
610
682
  });
611
683
  }
612
684
 
613
- function instrumentLayoutSource(code, siteDataImport) {
614
- if (/SiteDataProvider|DenebDataProvider/.test(code)) {
615
- if (/suppressHydrationWarning/.test(code)) {
616
- return { code, updated: false };
617
- }
618
- const ast = parseSource(code, 'layout.tsx');
619
- ensureHtmlBodyHydration(ast);
620
- return { code: printSource(ast, code), updated: true };
685
+ function findSiteDataJsonLocalName(ast) {
686
+ const program = ast.program || ast;
687
+ for (const node of program.body || []) {
688
+ if (node.type !== 'ImportDeclaration') continue;
689
+ const src = node.source && node.source.value;
690
+ if (typeof src !== 'string' || !/site-data\.json/.test(src)) continue;
691
+ const spec = (node.specifiers || []).find((s) => s.type === 'ImportDefaultSpecifier');
692
+ if (spec && spec.local) return spec.local.name;
621
693
  }
694
+ return null;
695
+ }
622
696
 
623
- const ast = parseSource(code, 'layout.tsx');
624
- ensureHtmlBodyHydration(ast);
625
- ensureImport(ast, '@deneb-ui/ui', ['SiteDataProvider']);
626
- ensureDefaultImport(ast, siteDataImport, 'initialSiteData');
627
-
628
- let wrapped = false;
697
+ function ensureProviderInitialData(ast, ident) {
698
+ let added = false;
629
699
  recast.types.visit(ast, {
630
- visitJSXExpressionContainer(pathNode) {
631
- if (wrapped) return false;
632
- const expr = pathNode.node.expression;
633
- if (expr && expr.type === 'Identifier' && expr.name === 'children') {
634
- pathNode.replace(
635
- b.jsxElement(
636
- b.jsxOpeningElement(
637
- b.jsxIdentifier('SiteDataProvider'),
638
- [
639
- b.jsxAttribute(
640
- b.jsxIdentifier('initialSiteData'),
641
- b.jsxExpressionContainer(b.identifier('initialSiteData'))
642
- ),
643
- ],
644
- false
645
- ),
646
- b.jsxClosingElement(b.jsxIdentifier('SiteDataProvider')),
647
- [pathNode.node],
648
- false
649
- )
650
- );
651
- wrapped = true;
652
- return false;
700
+ visitJSXOpeningElement(pathNode) {
701
+ const name = pathNode.node.name;
702
+ const tag = name && name.type === 'JSXIdentifier' ? name.name : '';
703
+ if (tag === 'SiteDataProvider' || tag === 'DenebDataProvider' || tag === 'DenebSiteDataProvider') {
704
+ const has = hasJsxAttribute(pathNode.node, 'initialSiteData');
705
+ if (!has) {
706
+ pathNode.node.attributes = pathNode.node.attributes || [];
707
+ pathNode.node.attributes.push(
708
+ b.jsxAttribute(
709
+ b.jsxIdentifier('initialSiteData'),
710
+ b.jsxExpressionContainer(b.identifier(ident))
711
+ )
712
+ );
713
+ added = true;
714
+ }
653
715
  }
654
716
  this.traverse(pathNode);
655
717
  },
656
718
  });
719
+ return added;
720
+ }
657
721
 
658
- if (!wrapped) {
722
+ const CANONICAL_SITE_DATA_CONTEXT = `'use client';
723
+
724
+ export {
725
+ SiteDataProvider,
726
+ useSiteData,
727
+ contentText,
728
+ contentObject,
729
+ contentList,
730
+ PREVIEW_DATA_MESSAGE,
731
+ LEGACY_PREVIEW_DATA_MESSAGE,
732
+ PREVIEW_READY_MESSAGE,
733
+ LEGACY_PREVIEW_READY_MESSAGE,
734
+ PREVIEW_FOCUS_MESSAGE,
735
+ LEGACY_PREVIEW_FOCUS_MESSAGE,
736
+ PREVIEW_FIELD_ATTRIBUTE,
737
+ } from '@deneb-ui/ui';
738
+
739
+ export type { SiteData, SiteDataProviderProps } from '@deneb-ui/ui';
740
+ `;
741
+
742
+ function rewriteRecursiveSiteDataContext(code) {
743
+ const recursive =
744
+ /export\s+function\s+SiteDataProvider\b/.test(code) &&
745
+ /<(?:Base)?SiteDataProvider\b/.test(code);
746
+ if (!recursive) return { code, updated: false };
747
+ return { code: CANONICAL_SITE_DATA_CONTEXT, updated: true };
748
+ }
749
+
750
+ function instrumentLayoutSource(code, siteDataImport, providerImport = '@deneb-ui/ui') {
751
+ const ast = parseSource(code, 'layout.tsx');
752
+ ensureHtmlBodyHydration(ast);
753
+
754
+ let jsonIdent = findSiteDataJsonLocalName(ast);
755
+ if (!jsonIdent) {
756
+ jsonIdent = 'initialSiteData';
757
+ ensureDefaultImport(ast, siteDataImport, jsonIdent);
758
+ }
759
+
760
+ const hasProvider = /SiteDataProvider|DenebDataProvider/.test(code);
761
+ let wrapped = hasProvider;
762
+
763
+ if (!hasProvider) {
764
+ ensureImport(ast, providerImport, ['SiteDataProvider']);
659
765
  recast.types.visit(ast, {
660
- visitJSXElement(pathNode) {
766
+ visitJSXExpressionContainer(pathNode) {
661
767
  if (wrapped) return false;
662
- const name = getJsxName(pathNode.node);
663
- if (name === 'Component') {
768
+ const expr = pathNode.node.expression;
769
+ if (expr && expr.type === 'Identifier' && expr.name === 'children') {
664
770
  pathNode.replace(
665
771
  b.jsxElement(
666
772
  b.jsxOpeningElement(
@@ -668,7 +774,7 @@ function instrumentLayoutSource(code, siteDataImport) {
668
774
  [
669
775
  b.jsxAttribute(
670
776
  b.jsxIdentifier('initialSiteData'),
671
- b.jsxExpressionContainer(b.identifier('initialSiteData'))
777
+ b.jsxExpressionContainer(b.identifier(jsonIdent))
672
778
  ),
673
779
  ],
674
780
  false
@@ -684,9 +790,43 @@ function instrumentLayoutSource(code, siteDataImport) {
684
790
  this.traverse(pathNode);
685
791
  },
686
792
  });
793
+
794
+ if (!wrapped) {
795
+ recast.types.visit(ast, {
796
+ visitJSXElement(pathNode) {
797
+ if (wrapped) return false;
798
+ const name = getJsxName(pathNode.node);
799
+ if (name === 'Component') {
800
+ pathNode.replace(
801
+ b.jsxElement(
802
+ b.jsxOpeningElement(
803
+ b.jsxIdentifier('SiteDataProvider'),
804
+ [
805
+ b.jsxAttribute(
806
+ b.jsxIdentifier('initialSiteData'),
807
+ b.jsxExpressionContainer(b.identifier(jsonIdent))
808
+ ),
809
+ ],
810
+ false
811
+ ),
812
+ b.jsxClosingElement(b.jsxIdentifier('SiteDataProvider')),
813
+ [pathNode.node],
814
+ false
815
+ )
816
+ );
817
+ wrapped = true;
818
+ return false;
819
+ }
820
+ this.traverse(pathNode);
821
+ },
822
+ });
823
+ }
687
824
  }
688
825
 
689
- return { code: printSource(ast, code), updated: wrapped };
826
+ ensureProviderInitialData(ast, jsonIdent);
827
+ sanitizeDuplicateBindings(ast);
828
+ const next = printSource(ast, code);
829
+ return { code: next, updated: next !== code };
690
830
  }
691
831
 
692
832
  function ensureJsonModule(tsconfig) {
@@ -752,6 +892,8 @@ module.exports = {
752
892
  instrumentLayoutSource,
753
893
  instrumentPageKey,
754
894
  resolveSiteDataSpecifier,
895
+ resolveSiteDataRuntimeSpecifier,
896
+ rewriteRecursiveSiteDataContext,
755
897
  ensureJsonModule,
756
898
  inferPageKey,
757
899
  sanitizeContradictoryMarkers,
@@ -615,8 +615,8 @@ function transformFileContent(filePath, pageKey, extractedData, backupDir, proje
615
615
  code = "'use client';\n\n" + code;
616
616
  }
617
617
 
618
- // Add useSiteData import if needed
619
- if (!code.includes('useSiteData')) {
618
+ // Add useSiteData import if needed. Skip when any binding already exists.
619
+ if (!/\buseSiteData\b/.test(code)) {
620
620
  code = code.replace(
621
621
  /(import\s+[^;]+;\n)/,
622
622
  `$1import { useSiteData } from '@deneb-ui/ui';\n`