@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.
@@ -11,6 +11,43 @@ const {
11
11
  wildcardPath,
12
12
  } = require('./fivora-contract.cjs');
13
13
 
14
+ function pruneUnboundLeaves(content, isBound) {
15
+ function walk(node, path) {
16
+ if (Array.isArray(node)) {
17
+ node.forEach((item, index) => {
18
+ if (item && typeof item === 'object') walk(item, `${path}[${index}]`);
19
+ });
20
+ return;
21
+ }
22
+ if (!isPlainObject(node)) return;
23
+ for (const key of Object.keys(node)) {
24
+ const next = path ? `${path}.${key}` : key;
25
+ const value = node[key];
26
+ if (Array.isArray(value)) {
27
+ const listBound =
28
+ isBound(next) || isBound(`${next}[0]`) || isBound(`${next}[*]`);
29
+ if (!listBound && !isAllowedControlOnly(next)) {
30
+ delete node[key];
31
+ continue;
32
+ }
33
+ walk(value, next);
34
+ continue;
35
+ }
36
+ if (isPlainObject(value)) {
37
+ walk(value, next);
38
+ if (Object.keys(value).length === 0 && !isBound(next) && !isAllowedControlOnly(next)) {
39
+ delete node[key];
40
+ }
41
+ continue;
42
+ }
43
+ if (!isBound(next) && !isAllowedControlOnly(next)) {
44
+ delete node[key];
45
+ }
46
+ }
47
+ }
48
+ walk(content, '');
49
+ }
50
+
14
51
  function setDeep(target, pathStr, value) {
15
52
  const parts = String(pathStr).split('.').filter(Boolean);
16
53
  let curr = target;
@@ -254,11 +291,32 @@ function baseContent(projectName, routes) {
254
291
 
255
292
  /**
256
293
  * Fivora strict mode requires every concrete site-data field to either render a
257
- * data-preview-field-path marker or be declared control-only. ARC always emits
258
- * a merchant-facing baseline (business phone, logo, nav labels) that an
259
- * arbitrary project may not render, so every unbound path is declared here
260
- * instead of being silently shipped as an ingest failure.
294
+ * data-preview-field-path marker or be declared control-only. Control-only is
295
+ * reserved for ids, internal flags, and the unrendered merchant baseline — not
296
+ * as a dump for fields ARC planned but failed to bind.
261
297
  */
298
+ const BASELINE_CONTROL_ONLY = /^(common\.(websiteTitle|shortDescription|logoUrl|headerCtaLabel|copyright|navLabels(\.[^.]+)?|business(\.[^.]+)*))$/;
299
+ const SYSTEM_FIELD = /(^|\.)(id|key|slug|internalId|sku|_id)$/i;
300
+
301
+ function slimListItems(items, itemFields) {
302
+ const keys = (itemFields || []).map((field) => field.key).filter(Boolean);
303
+ if (!keys.length || !Array.isArray(items)) return items || [];
304
+ return items.map((item) => {
305
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
306
+ const out = {};
307
+ for (const key of keys) {
308
+ if (!(key in item)) continue;
309
+ if (Array.isArray(item[key])) continue;
310
+ out[key] = item[key];
311
+ }
312
+ return Object.keys(out).length ? out : item;
313
+ });
314
+ }
315
+
316
+ function isAllowedControlOnly(path) {
317
+ return BASELINE_CONTROL_ONLY.test(path) || SYSTEM_FIELD.test(path);
318
+ }
319
+
262
320
  function computeControlOnlyPaths(content, boundPaths, declared = []) {
263
321
  const inventory = enumerateContentPaths(content);
264
322
  const bound = new Set([...(boundPaths || [])].map(wildcardPath));
@@ -266,9 +324,7 @@ function computeControlOnlyPaths(content, boundPaths, declared = []) {
266
324
 
267
325
  for (const declaredPath of declared) {
268
326
  const canonical = canonicalizeMarkerPath(declaredPath);
269
- if (!canonical) continue;
270
- // Drop stale declarations that no longer exist in content; the platform
271
- // rejects controlOnlyPaths entries it cannot resolve.
327
+ if (!canonical || !isAllowedControlOnly(canonical)) continue;
272
328
  const known = [...inventory.fieldPatterns, ...inventory.concreteFields].some(
273
329
  (path) => wildcardPath(path) === wildcardPath(canonical)
274
330
  );
@@ -276,7 +332,8 @@ function computeControlOnlyPaths(content, boundPaths, declared = []) {
276
332
  }
277
333
 
278
334
  for (const path of inventory.concreteFields) {
279
- if (!bound.has(wildcardPath(path))) controlOnly.add(path);
335
+ if (bound.has(wildcardPath(path))) continue;
336
+ if (isAllowedControlOnly(path)) controlOnly.add(path);
280
337
  }
281
338
 
282
339
  return [...controlOnly].sort();
@@ -314,6 +371,8 @@ function buildSiteDataAndManifest({
314
371
  existingSiteData,
315
372
  existingManifest,
316
373
  boundFieldPaths = [],
374
+ boundListPaths = [],
375
+ extraFields = [],
317
376
  markerRoutes = {},
318
377
  }) {
319
378
  const routes = (profile.routes && profile.routes.length ? profile.routes : [{ id: 'home', label: 'Home', route: '/', required: true }])
@@ -329,7 +388,15 @@ function buildSiteDataAndManifest({
329
388
  // Recipes may hint schema shape, but must not dump another storefront's content
330
389
  // into an unrelated project. Extracted values always win.
331
390
 
332
- const plannedFields = collectFieldsFromPlan(plan);
391
+ const plannedFields = [...collectFieldsFromPlan(plan), ...(extraFields || [])];
392
+ const boundSet = new Set(
393
+ [...(boundFieldPaths || []), ...(boundListPaths || [])].map((path) => wildcardPath(path))
394
+ );
395
+ function isBound(path) {
396
+ if (!path) return false;
397
+ const wild = wildcardPath(path);
398
+ return boundSet.has(wild) || [...boundSet].some((marker) => wildcardPath(marker) === wild);
399
+ }
333
400
 
334
401
  // A second `init` run re-reads already-transformed sources, where the former
335
402
  // literals are now site-data expressions and therefore no longer detectable.
@@ -373,8 +440,15 @@ function buildSiteDataAndManifest({
373
440
 
374
441
  for (const field of plannedFields) {
375
442
  if (field.type === 'list') {
376
- setDeep(content, field.path, field.value ?? []);
377
- upsertSchemaList(editorSections, field.path, field.itemFields, field.value);
443
+ if (!isBound(field.path) && !isBound(`${field.path}[0]`) && !isBound(`${field.path}[*]`)) continue;
444
+ const value = slimListItems(field.value ?? [], field.itemFields);
445
+ setDeep(content, field.path, value);
446
+ upsertSchemaList(editorSections, field.path, field.itemFields, value);
447
+ continue;
448
+ }
449
+ if (!isBound(field.path) && !isAllowedControlOnly(field.path)) continue;
450
+ if (!isBound(field.path) && isAllowedControlOnly(field.path)) {
451
+ setDeep(content, field.path, field.value ?? '');
378
452
  continue;
379
453
  }
380
454
  setDeep(content, field.path, field.value ?? '');
@@ -387,12 +461,15 @@ function buildSiteDataAndManifest({
387
461
  // Planned extracted values should win over empty recipe defaults when existing is absent,
388
462
  // but never erase merchant-configured existing values.
389
463
  for (const field of plannedFields) {
464
+ if (!isBound(field.path) && field.type !== 'list' && !isAllowedControlOnly(field.path)) continue;
390
465
  const existingVal = getDeep(existingSiteData.content, field.path);
391
466
  if (existingVal !== undefined) setDeep(content, field.path, existingVal);
392
467
  else if (field.value !== undefined) setDeep(content, field.path, field.value);
393
468
  }
394
469
  }
395
470
 
471
+ pruneUnboundLeaves(content, isBound);
472
+
396
473
  const siteData = {
397
474
  denebVersion: existingSiteData?.denebVersion || undefined,
398
475
  arcVersion: ARC_VERSION,
@@ -503,4 +580,5 @@ module.exports = {
503
580
  upsertSchemaList,
504
581
  enrichSchemasFromContent,
505
582
  isListActionCtaKey,
583
+ isAllowedControlOnly,
506
584
  };
@@ -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,
@@ -186,7 +189,8 @@ function planTransformations({ profile, analyses, recipe }) {
186
189
  type: typeof sample[k] === 'number' ? 'number' : /image|photo|avatar/i.test(k) ? 'image' : /url|link/i.test(k) ? 'url' : 'text',
187
190
  }));
188
191
  }
189
- if (!transform.itemFields.length || !extra.objectItems || extra.hasComponentRef) transform.decision = 'skip';
192
+ transform.hasComponentRef = Boolean(extra.hasComponentRef);
193
+ if (!transform.itemFields.length || !extra.objectItems) transform.decision = 'skip';
190
194
  } else {
191
195
  transform.field = buildFieldPath({
192
196
  scope,
@@ -238,7 +242,7 @@ function sectionForAction(scope, section, extra) {
238
242
  if (scope === 'common' && (extra.social || ['instagram', 'facebook', 'twitter', 'tiktok', 'youtube', 'linkedin'].includes(extra.action))) {
239
243
  return 'footer';
240
244
  }
241
- if (['whatsapp', 'phone', 'email', 'directions', 'location'].includes(extra.action)) {
245
+ if (['whatsapp', 'phone', 'email', 'directions', 'location', 'form-submit'].includes(extra.action)) {
242
246
  return section || 'contact';
243
247
  }
244
248
  if (extra.action === 'shop') {
@@ -84,9 +84,12 @@ function printValidation(validation, coverage, design) {
84
84
  else warn('AST validation reported parse issues');
85
85
  if (validation.contractPassed) ok('editable contracts');
86
86
  else warn('editable contract issues detected');
87
+ if (validation.fivoraContractPassed) ok('Fivora strict contract');
88
+ else warn('Fivora strict contract failed — package is not upload-ready');
87
89
  ok('manifest');
88
90
  if (validation.idempotencyPassed !== false) ok('idempotency');
89
91
  console.log('');
92
+ console.log(` Visual coverage: ${coverage.visualCoverage != null ? coverage.visualCoverage : coverage.editableCoverage}%`);
90
93
  console.log(` Editable coverage: ${coverage.editableCoverage}%`);
91
94
  console.log(` Design preservation: ${design.score}%`);
92
95
  }
@@ -135,6 +138,11 @@ function printSuccess() {
135
138
  console.log(`\n${C.green}${C.bold}Deneb ARC completed successfully.${C.reset}\n`);
136
139
  }
137
140
 
141
+ function printContractFailed() {
142
+ console.log(`\n${C.yellow}${C.bold}Deneb ARC finished with Fivora contract findings.${C.reset}`);
143
+ console.log(`${C.dim}Files were kept for debugging. This package is not upload-ready. Re-run with --strict to roll back.${C.reset}\n`);
144
+ }
145
+
138
146
  function printUncoveredText(findings = []) {
139
147
  if (!findings.length) return;
140
148
  warn(`${findings.length} visible text node(s) still uncovered — run deneb validate . before packaging`);
@@ -229,6 +237,7 @@ module.exports = {
229
237
  printDryRun,
230
238
  printError,
231
239
  printSuccess,
240
+ printContractFailed,
232
241
  printUncoveredText,
233
242
  printDeveloperNextSteps,
234
243
  printRollback,
@@ -0,0 +1,400 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pass B of the ARC planner: residual bind-or-static closure.
5
+ *
6
+ * Confidence cannot rescue dynamic leftovers (c_sem = 0.15). After Pass A,
7
+ * every remaining visible literal is either bound to a field or marked
8
+ * data-preview-static with a reason. This pass ignores the 0.60 skip floor.
9
+ */
10
+
11
+ const recast = require('recast');
12
+ const {
13
+ parseSource,
14
+ printSource,
15
+ getJsxName,
16
+ hasJsxAttribute,
17
+ findJsxAttribute,
18
+ collectJsxText,
19
+ wrapTextInEditableSpan,
20
+ jsxPreviewAttr,
21
+ jsxStaticAttr,
22
+ ensureStyleAttrs,
23
+ siteDataBinding,
24
+ b,
25
+ } = require('./ast.cjs');
26
+ const {
27
+ SKIP_TAGS,
28
+ HEADING_TAGS,
29
+ TEXT_TAGS,
30
+ ACTION_TAGS,
31
+ IMAGE_TAGS,
32
+ DECORATIVE_TAGS,
33
+ isIconComponent,
34
+ } = require('./adapters.cjs');
35
+ const { BROAD_CONTENT_CONTAINERS } = require('./fivora-contract.cjs');
36
+ const { inferSection, inferFieldName, buildFieldPath, classifyFieldType } = require('./field-paths.cjs');
37
+ const { isStaticSkipText } = require('./semantic.cjs');
38
+
39
+ const LEAF_TEXT_TAGS = new Set([
40
+ ...HEADING_TAGS,
41
+ ...TEXT_TAGS,
42
+ 'em', 'strong', 'small', 'b', 'i', 'u', 'code', 'time', 'cite', 'mark',
43
+ 'dt', 'dd', 'th', 'td', 'legend', 'caption',
44
+ ]);
45
+
46
+ const CHROME_TEXT_RE = /^(×|x|✕|✖|\+|−|-|•|·|…|\.|…|\||\/|©|®|™|\d+)$/i;
47
+ const CART_CHROME_RE = /^(qty|quantity|subtotal|total|cart|checkout|remove|close|menu)$/i;
48
+
49
+ function attrLiteral(node, name) {
50
+ const attr = findJsxAttribute(node, name);
51
+ if (!attr || !attr.value) return '';
52
+ if (attr.value.type === 'StringLiteral' || attr.value.type === 'Literal') return String(attr.value.value || '');
53
+ if (attr.value.type === 'JSXExpressionContainer') {
54
+ const expr = attr.value.expression;
55
+ if (expr && (expr.type === 'StringLiteral' || expr.type === 'Literal')) return String(expr.value || '');
56
+ }
57
+ return '';
58
+ }
59
+
60
+ function hasEditableMarker(node) {
61
+ return (
62
+ hasJsxAttribute(node, 'data-preview-field-path') ||
63
+ hasJsxAttribute(node, 'data-preview-list-path') ||
64
+ hasJsxAttribute(node, 'data-preview-item-path') ||
65
+ hasJsxAttribute(node, 'data-preview-static')
66
+ );
67
+ }
68
+
69
+ function ancestorHasStatic(pathNode) {
70
+ let current = pathNode.parent;
71
+ while (current) {
72
+ const node = current.node || current.value;
73
+ if (node && node.type === 'JSXElement' && hasJsxAttribute(node, 'data-preview-static')) return true;
74
+ current = current.parentPath || current.parent;
75
+ }
76
+ return false;
77
+ }
78
+
79
+ function inMapCallback(pathNode) {
80
+ let current = pathNode.parent;
81
+ while (current) {
82
+ const node = current.node || current.value;
83
+ if (
84
+ node &&
85
+ node.type === 'CallExpression' &&
86
+ node.callee &&
87
+ node.callee.type === 'MemberExpression' &&
88
+ node.callee.property &&
89
+ node.callee.property.name === 'map'
90
+ ) {
91
+ return true;
92
+ }
93
+ current = current.parentPath || current.parent;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ function isMeaningfulVisibleText(text) {
99
+ const value = String(text || '').replace(/\s+/g, ' ').trim();
100
+ if (!value || value.length <= 2) return false;
101
+ if (!/\p{L}/u.test(value)) return false;
102
+ if (isStaticSkipText(value)) return false;
103
+ if (CHROME_TEXT_RE.test(value)) return false;
104
+ return true;
105
+ }
106
+
107
+ function isDecorativeCopy(text, tag, className) {
108
+ const value = String(text || '').replace(/\s+/g, ' ').trim();
109
+ if (!value) return true;
110
+ if (CHROME_TEXT_RE.test(value)) return true;
111
+ if (CART_CHROME_RE.test(value) && /cart|drawer|qty|quantity/i.test(className || '')) return true;
112
+ if (DECORATIVE_TAGS.has(tag) || tag === 'svg') return true;
113
+ if (/\bsr-only\b|\bhidden\b/.test(className || '')) return true;
114
+ return false;
115
+ }
116
+
117
+ function classifyResidual(node, text, tag) {
118
+ const className = attrLiteral(node, 'className') || attrLiteral(node, 'class');
119
+ const ariaHidden = attrLiteral(node, 'aria-hidden') === 'true' || hasJsxAttribute(node, 'hidden') || /(^|\s)hidden(\s|$)/.test(className);
120
+ if (ariaHidden || DECORATIVE_TAGS.has(tag) || isIconComponent(tag, '')) {
121
+ return { bind: false, reason: 'decorative-icon' };
122
+ }
123
+ if (isDecorativeCopy(text, tag, className)) {
124
+ return { bind: false, reason: 'chrome' };
125
+ }
126
+ if (IMAGE_TAGS.has(tag) && attrLiteral(node, 'src')) {
127
+ return { bind: true, kind: 'image', value: attrLiteral(node, 'src') };
128
+ }
129
+ if (isMeaningfulVisibleText(text)) {
130
+ return { bind: true, kind: 'text', value: text };
131
+ }
132
+ if (text && text.trim()) {
133
+ return { bind: false, reason: 'decorative-copy' };
134
+ }
135
+ return null;
136
+ }
137
+
138
+ function ensureStaticOnLeaf(node, reason) {
139
+ const tag = getJsxName(node);
140
+ if (BROAD_CONTENT_CONTAINERS.has(tag.toLowerCase()) || BROAD_CONTENT_CONTAINERS.has(tag)) {
141
+ return false;
142
+ }
143
+ if (hasJsxAttribute(node, 'data-preview-static') || hasEditableMarker(node)) return false;
144
+ node.openingElement.attributes.push(jsxStaticAttr(reason || 'decorative'));
145
+ return true;
146
+ }
147
+
148
+ function wrapFirstLiteralAsStatic(node, reason) {
149
+ const nextChildren = [];
150
+ let wrapped = false;
151
+ for (const child of node.children || []) {
152
+ if (!wrapped && child.type === 'JSXText' && child.value.replace(/\s+/g, '').length) {
153
+ const leading = child.value.match(/^\s*/)?.[0] || '';
154
+ const trailing = child.value.match(/\s*$/)?.[0] || '';
155
+ const text = child.value.trim();
156
+ if (leading) nextChildren.push(b.jsxText(leading));
157
+ nextChildren.push(
158
+ b.jsxElement(
159
+ b.jsxOpeningElement(b.jsxIdentifier('span'), [jsxStaticAttr(reason || 'decorative')], false),
160
+ b.jsxClosingElement(b.jsxIdentifier('span')),
161
+ [b.jsxText(text)],
162
+ false
163
+ )
164
+ );
165
+ if (trailing) nextChildren.push(b.jsxText(trailing));
166
+ wrapped = true;
167
+ continue;
168
+ }
169
+ nextChildren.push(child);
170
+ }
171
+ if (wrapped) node.children = nextChildren;
172
+ return wrapped;
173
+ }
174
+
175
+ function applyResidualPass({ code, file, ownerScope, usedPaths, componentName, role }) {
176
+ if (!code || !code.includes('<')) {
177
+ return { code, changed: false, fields: [], applied: 0 };
178
+ }
179
+
180
+ let ast;
181
+ try {
182
+ ast = parseSource(code, file);
183
+ } catch {
184
+ return { code, changed: false, fields: [], applied: 0 };
185
+ }
186
+
187
+ const fields = [];
188
+ let applied = 0;
189
+ const used = usedPaths instanceof Set ? usedPaths : new Set(usedPaths || []);
190
+
191
+ recast.types.visit(ast, {
192
+ visitJSXElement(pathNode) {
193
+ const node = pathNode.node;
194
+ const tag = getJsxName(node);
195
+ const lower = tag.toLowerCase();
196
+
197
+ if (!tag || SKIP_TAGS.has(tag) || tag === 'React.Fragment' || tag === 'Fragment') {
198
+ this.traverse(pathNode);
199
+ return;
200
+ }
201
+
202
+ if (hasEditableMarker(node) || ancestorHasStatic(pathNode)) {
203
+ this.traverse(pathNode);
204
+ return;
205
+ }
206
+
207
+ if (DECORATIVE_TAGS.has(tag) || isIconComponent(tag, '')) {
208
+ if (ensureStaticOnLeaf(node, 'decorative-icon')) applied++;
209
+ this.traverse(pathNode);
210
+ return;
211
+ }
212
+
213
+ const text = collectJsxText(node);
214
+ const src = attrLiteral(node, 'src');
215
+ const placeholder = attrLiteral(node, 'placeholder');
216
+ const alt = attrLiteral(node, 'alt');
217
+
218
+ const decision = classifyResidual(node, text, tag);
219
+ const insideMap = inMapCallback(pathNode);
220
+
221
+ if (insideMap && decision && decision.bind && ACTION_TAGS.has(tag)) {
222
+ // Repeated chrome inside a list stays static so we never couple parallel arrays.
223
+ if (BROAD_CONTENT_CONTAINERS.has(lower)) {
224
+ if (wrapFirstLiteralAsStatic(node, 'collection-chrome')) applied++;
225
+ } else if (ensureStaticOnLeaf(node, 'collection-chrome')) {
226
+ applied++;
227
+ }
228
+ this.traverse(pathNode);
229
+ return;
230
+ }
231
+
232
+ if (IMAGE_TAGS.has(tag) && src && !src.startsWith('{') && !hasJsxAttribute(node, 'data-preview-field-path')) {
233
+ const section = inferSection({
234
+ componentName,
235
+ fileName: file,
236
+ className: attrLiteral(node, 'className'),
237
+ tag,
238
+ role,
239
+ });
240
+ const field = buildFieldPath({
241
+ scope: ownerScope || 'home',
242
+ section,
243
+ field: inferFieldName('image', tag, src, {}),
244
+ used,
245
+ });
246
+ node.openingElement.attributes.push(jsxPreviewAttr(field));
247
+ const srcAttr = findJsxAttribute(node, 'src');
248
+ if (srcAttr) {
249
+ srcAttr.value = b.jsxExpressionContainer(siteDataBinding(field.split('.'), src, 'image'));
250
+ }
251
+ fields.push({ path: field, type: 'image', value: src });
252
+ applied++;
253
+ this.traverse(pathNode);
254
+ return;
255
+ }
256
+
257
+ if (placeholder && !hasJsxAttribute(node, 'data-preview-field-path')) {
258
+ const section = inferSection({ componentName, fileName: file, tag, role });
259
+ const field = buildFieldPath({
260
+ scope: ownerScope || 'home',
261
+ section,
262
+ field: inferFieldName('placeholder', tag, placeholder, {}),
263
+ used,
264
+ });
265
+ node.openingElement.attributes.push(jsxPreviewAttr(field));
266
+ const ph = findJsxAttribute(node, 'placeholder');
267
+ if (ph) {
268
+ ph.value = b.jsxExpressionContainer(siteDataBinding(field.split('.'), placeholder, 'text'));
269
+ }
270
+ fields.push({ path: field, type: 'text', value: placeholder });
271
+ applied++;
272
+ }
273
+
274
+ if (alt && isMeaningfulVisibleText(alt) && !hasJsxAttribute(node, 'data-preview-field-path')) {
275
+ const section = inferSection({ componentName, fileName: file, tag, role });
276
+ const field = buildFieldPath({
277
+ scope: ownerScope || 'home',
278
+ section,
279
+ field: inferFieldName('alt', tag, alt, {}),
280
+ used,
281
+ });
282
+ const altAttr = findJsxAttribute(node, 'alt');
283
+ if (altAttr) {
284
+ altAttr.value = b.jsxExpressionContainer(siteDataBinding(field.split('.'), alt, 'text'));
285
+ }
286
+ fields.push({ path: field, type: 'text', value: alt });
287
+ applied++;
288
+ }
289
+
290
+ if (decision && decision.bind && decision.kind === 'text' && isMeaningfulVisibleText(text)) {
291
+ const section = inferSection({
292
+ componentName,
293
+ fileName: file,
294
+ className: attrLiteral(node, 'className'),
295
+ tag,
296
+ role,
297
+ });
298
+ const field = buildFieldPath({
299
+ scope: ownerScope || 'home',
300
+ section,
301
+ field: inferFieldName('text', tag, text, { tag }),
302
+ used,
303
+ });
304
+ const fieldType = classifyFieldType('text', text);
305
+
306
+ if (BROAD_CONTENT_CONTAINERS.has(lower) || BROAD_CONTENT_CONTAINERS.has(tag)) {
307
+ const nextChildren = [];
308
+ let wrapped = false;
309
+ for (const child of node.children || []) {
310
+ if (!wrapped && child.type === 'JSXText' && child.value.replace(/\s+/g, '').length) {
311
+ const leading = child.value.match(/^\s*/)?.[0] || '';
312
+ const trailing = child.value.match(/\s*$/)?.[0] || '';
313
+ if (leading) nextChildren.push(b.jsxText(leading));
314
+ nextChildren.push(wrapTextInEditableSpan(field, text, fieldType));
315
+ if (trailing) nextChildren.push(b.jsxText(trailing));
316
+ wrapped = true;
317
+ continue;
318
+ }
319
+ nextChildren.push(child);
320
+ }
321
+ if (wrapped) {
322
+ node.children = nextChildren;
323
+ fields.push({ path: field, type: fieldType, value: text });
324
+ applied++;
325
+ }
326
+ } else if (LEAF_TEXT_TAGS.has(tag) || HEADING_TAGS.has(tag) || tag === 'button' || tag === 'Button') {
327
+ if (ACTION_TAGS.has(tag) && (hasJsxAttribute(node, 'href') || tag === 'a' || tag === 'Link')) {
328
+ const nextChildren = [];
329
+ let wrapped = false;
330
+ for (const child of node.children || []) {
331
+ if (!wrapped && child.type === 'JSXText' && child.value.replace(/\s+/g, '').length) {
332
+ const leading = child.value.match(/^\s*/)?.[0] || '';
333
+ const trailing = child.value.match(/\s*$/)?.[0] || '';
334
+ if (leading) nextChildren.push(b.jsxText(leading));
335
+ nextChildren.push(wrapTextInEditableSpan(field, text, fieldType));
336
+ if (trailing) nextChildren.push(b.jsxText(trailing));
337
+ wrapped = true;
338
+ continue;
339
+ }
340
+ nextChildren.push(child);
341
+ }
342
+ if (wrapped) {
343
+ node.children = nextChildren;
344
+ fields.push({ path: field, type: fieldType, value: text });
345
+ applied++;
346
+ }
347
+ } else {
348
+ node.openingElement.attributes.push(jsxPreviewAttr(field));
349
+ ensureStyleAttrs(node, field, tag === 'button' || tag === 'Button' ? 'button' : 'text');
350
+ const nextChildren = [];
351
+ let replaced = false;
352
+ for (const child of node.children || []) {
353
+ if (!replaced && child.type === 'JSXText' && child.value.replace(/\s+/g, '').length) {
354
+ nextChildren.push(b.jsxExpressionContainer(siteDataBinding(field.split('.'), text, fieldType)));
355
+ replaced = true;
356
+ } else {
357
+ nextChildren.push(child);
358
+ }
359
+ }
360
+ if (replaced) node.children = nextChildren;
361
+ fields.push({ path: field, type: fieldType, value: text });
362
+ applied++;
363
+ }
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++;
368
+ }
369
+ this.traverse(pathNode);
370
+ return;
371
+ }
372
+
373
+ if (decision && !decision.bind && text) {
374
+ if (BROAD_CONTENT_CONTAINERS.has(lower)) {
375
+ if (wrapFirstLiteralAsStatic(node, decision.reason || 'decorative')) applied++;
376
+ } else if (ensureStaticOnLeaf(node, decision.reason || 'decorative')) {
377
+ applied++;
378
+ }
379
+ }
380
+
381
+ this.traverse(pathNode);
382
+ },
383
+ });
384
+
385
+ if (applied === 0) {
386
+ return { code, changed: false, fields: [], applied: 0 };
387
+ }
388
+
389
+ return {
390
+ code: printSource(ast, code),
391
+ changed: true,
392
+ fields,
393
+ applied,
394
+ };
395
+ }
396
+
397
+ module.exports = {
398
+ applyResidualPass,
399
+ isMeaningfulVisibleText,
400
+ };