@deneb-ui/cli 2.0.50 → 2.0.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/index.js +12 -0
- package/package.json +2 -2
- package/src/arc/__tests__/arc.test.cjs +219 -10
- package/src/arc/adapters.cjs +19 -0
- package/src/arc/field-paths.cjs +4 -0
- package/src/arc/fivora-contract.cjs +266 -5
- package/src/arc/index.cjs +129 -30
- package/src/arc/learning.cjs +20 -1
- package/src/arc/manifest.cjs +89 -11
- package/src/arc/planner.cjs +10 -6
- package/src/arc/printer.cjs +9 -0
- package/src/arc/residual.cjs +400 -0
- package/src/arc/scanner.cjs +46 -2
- package/src/arc/semantic.cjs +47 -8
- package/src/arc/style-candidates.cjs +2 -1
- package/src/arc/transformer.cjs +335 -20
- package/src/arc/validator.cjs +43 -3
- package/src/arc/version.cjs +1 -1
package/src/arc/manifest.cjs
CHANGED
|
@@ -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.
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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 (
|
|
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
|
-
|
|
377
|
-
|
|
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
|
};
|
package/src/arc/planner.cjs
CHANGED
|
@@ -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
|
-
|
|
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') {
|
package/src/arc/printer.cjs
CHANGED
|
@@ -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
|
+
};
|