@deneb-ui/cli 2.0.21 → 2.0.23
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/README.md +62 -114
- package/bin/index.js +101 -207
- package/package.json +20 -5
- package/src/arc/__fixtures__/next-app-basic/package.json +10 -0
- package/src/arc/__fixtures__/next-app-basic/src/app/globals.css +3 -0
- package/src/arc/__fixtures__/next-app-basic/src/app/layout.tsx +9 -0
- package/src/arc/__fixtures__/next-app-basic/src/app/page.tsx +11 -0
- package/src/arc/__fixtures__/next-app-basic/src/components/Header.tsx +13 -0
- package/src/arc/__fixtures__/next-app-basic/src/components/Hero.tsx +12 -0
- package/src/arc/__fixtures__/next-app-basic/src/components/PromoBanner.tsx +10 -0
- package/src/arc/__fixtures__/next-app-basic/tsconfig.json +12 -0
- package/src/arc/__fixtures__/next-app-storefront/components.json +14 -0
- package/src/arc/__fixtures__/next-app-storefront/package.json +22 -0
- package/src/arc/__fixtures__/next-app-storefront/src/app/about/page.tsx +13 -0
- package/src/arc/__fixtures__/next-app-storefront/src/app/globals.css +5 -0
- package/src/arc/__fixtures__/next-app-storefront/src/app/layout.tsx +19 -0
- package/src/arc/__fixtures__/next-app-storefront/src/app/page.tsx +13 -0
- package/src/arc/__fixtures__/next-app-storefront/src/components/Features.tsx +31 -0
- package/src/arc/__fixtures__/next-app-storefront/src/components/Hero.tsx +45 -0
- package/src/arc/__fixtures__/next-app-storefront/src/components/ProductGrid.tsx +49 -0
- package/src/arc/__fixtures__/next-app-storefront/src/components/SiteFooter.tsx +22 -0
- package/src/arc/__fixtures__/next-app-storefront/src/components/SiteHeader.tsx +21 -0
- package/src/arc/__fixtures__/next-app-storefront/src/components/ui/button.tsx +36 -0
- package/src/arc/__fixtures__/next-app-storefront/tsconfig.json +15 -0
- package/src/arc/__fixtures__/next-pages-basic/package.json +10 -0
- package/src/arc/__fixtures__/next-pages-basic/pages/_app.jsx +5 -0
- package/src/arc/__fixtures__/next-pages-basic/pages/contact.jsx +9 -0
- package/src/arc/__fixtures__/next-pages-basic/pages/index.jsx +11 -0
- package/src/arc/__fixtures__/next-pages-basic/styles/globals.css +9 -0
- package/src/arc/__tests__/arc.test.cjs +458 -0
- package/src/arc/adapters.cjs +184 -0
- package/src/arc/ast.cjs +323 -0
- package/src/arc/field-paths.cjs +165 -0
- package/src/arc/fivora-contract.cjs +521 -0
- package/src/arc/fs-utils.cjs +170 -0
- package/src/arc/index.cjs +628 -0
- package/src/arc/learning.cjs +185 -0
- package/src/arc/manifest.cjs +421 -0
- package/src/arc/next-config.cjs +279 -0
- package/src/arc/planner.cjs +227 -0
- package/src/arc/printer.cjs +153 -0
- package/src/arc/recipes-v2.cjs +49 -0
- package/src/arc/scanner.cjs +613 -0
- package/src/arc/semantic.cjs +651 -0
- package/src/arc/transformer.cjs +646 -0
- package/src/arc/validator.cjs +173 -0
- package/src/arc/version.cjs +22 -0
- package/src/recipes/cosmetics-beauty-store.json +1097 -0
- package/src/recipes/electronics-gadgets-store.json +1080 -0
- package/src/recipes/fashion-apparel-store.json +1074 -0
- package/src/tools/deneb-doctor.cjs +646 -0
- package/src/tools/recipe-engine.cjs +30 -0
- package/src/tools/template-converter.cjs +19 -6
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const recast = require('recast');
|
|
4
|
+
const {
|
|
5
|
+
parseSource,
|
|
6
|
+
locKey,
|
|
7
|
+
getJsxName,
|
|
8
|
+
getJsxAttributeLiteral,
|
|
9
|
+
hasJsxAttribute,
|
|
10
|
+
collectJsxText,
|
|
11
|
+
findJsxAttribute,
|
|
12
|
+
} = require('./ast.cjs');
|
|
13
|
+
const {
|
|
14
|
+
activeAdapters,
|
|
15
|
+
recognizeWithAdapters,
|
|
16
|
+
resolveActionWithAdapters,
|
|
17
|
+
isIconComponent,
|
|
18
|
+
classifyHref,
|
|
19
|
+
isLikelyCtaClass,
|
|
20
|
+
SKIP_TAGS,
|
|
21
|
+
HEADING_TAGS,
|
|
22
|
+
TEXT_TAGS,
|
|
23
|
+
ACTION_TAGS,
|
|
24
|
+
IMAGE_TAGS,
|
|
25
|
+
DECORATIVE_TAGS,
|
|
26
|
+
} = require('./adapters.cjs');
|
|
27
|
+
const { shortHash } = require('./fs-utils.cjs');
|
|
28
|
+
const { BROAD_CONTENT_CONTAINERS } = require('./fivora-contract.cjs');
|
|
29
|
+
|
|
30
|
+
const TECHNICAL_TEXT_RE = /^(true|false|null|undefined|px|rem|em|auto|hidden|flex|grid|sr-only)$/i;
|
|
31
|
+
const ARIA_ONLY_RE = /^(aria-|data-state|data-slot|data-orientation)/;
|
|
32
|
+
const SKIP_ATTR_NAMES = new Set(['className', 'class', 'style', 'key', 'id', 'role', 'type', 'name', 'htmlFor', 'suppressHydrationWarning']);
|
|
33
|
+
|
|
34
|
+
function fingerprintCandidate(features) {
|
|
35
|
+
return shortHash(JSON.stringify(features));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeText(value) {
|
|
39
|
+
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isStaticSkipText(text) {
|
|
43
|
+
const value = normalizeText(text);
|
|
44
|
+
if (!value || value.length < 2) return true;
|
|
45
|
+
if (TECHNICAL_TEXT_RE.test(value)) return true;
|
|
46
|
+
if (/^[{}`\\]/.test(value)) return true;
|
|
47
|
+
if (/^https?:\/\/(localhost|127\.0\.0\.1)/i.test(value)) return true;
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function collectStringBindings(ast) {
|
|
52
|
+
const bindings = new Map();
|
|
53
|
+
recast.types.visit(ast, {
|
|
54
|
+
visitVariableDeclarator(pathNode) {
|
|
55
|
+
const node = pathNode.node;
|
|
56
|
+
if (node.id && node.id.type === 'Identifier' && node.init) {
|
|
57
|
+
if (node.init.type === 'StringLiteral' || node.init.type === 'Literal' && typeof node.init.value === 'string') {
|
|
58
|
+
bindings.set(node.id.name, String(node.init.value));
|
|
59
|
+
}
|
|
60
|
+
if (node.init.type === 'ArrayExpression') {
|
|
61
|
+
const items = [];
|
|
62
|
+
let allLiteral = true;
|
|
63
|
+
for (const el of node.init.elements || []) {
|
|
64
|
+
if (!el) continue;
|
|
65
|
+
if (el.type === 'StringLiteral' || (el.type === 'Literal' && typeof el.value === 'string')) {
|
|
66
|
+
items.push({ type: 'string', value: String(el.value) });
|
|
67
|
+
} else if (el.type === 'ObjectExpression') {
|
|
68
|
+
const obj = objectLiteralToPlain(el);
|
|
69
|
+
if (obj) items.push({ type: 'object', value: obj });
|
|
70
|
+
else allLiteral = false;
|
|
71
|
+
} else {
|
|
72
|
+
allLiteral = false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (allLiteral && items.length) bindings.set(node.id.name, { kind: 'array', items });
|
|
76
|
+
}
|
|
77
|
+
if (node.init.type === 'ObjectExpression') {
|
|
78
|
+
const obj = objectLiteralToPlain(node.init);
|
|
79
|
+
if (obj) bindings.set(node.id.name, { kind: 'object', value: obj });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
this.traverse(pathNode);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
return bindings;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function objectLiteralToPlain(node) {
|
|
89
|
+
if (!node || node.type !== 'ObjectExpression') return null;
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const prop of node.properties || []) {
|
|
92
|
+
if (prop.type !== 'ObjectProperty' && prop.type !== 'Property') return null;
|
|
93
|
+
const key = prop.key && (prop.key.name || prop.key.value);
|
|
94
|
+
if (!key || prop.computed) return null;
|
|
95
|
+
const val = prop.value;
|
|
96
|
+
if (val.type === 'StringLiteral' || (val.type === 'Literal' && typeof val.value === 'string')) {
|
|
97
|
+
out[key] = String(val.value);
|
|
98
|
+
} else if (val.type === 'NumericLiteral' || (val.type === 'Literal' && typeof val.value === 'number')) {
|
|
99
|
+
out[key] = val.value;
|
|
100
|
+
} else if (val.type === 'BooleanLiteral' || (val.type === 'Literal' && typeof val.value === 'boolean')) {
|
|
101
|
+
out[key] = val.value;
|
|
102
|
+
} else {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function collectImportMap(ast) {
|
|
110
|
+
const map = {};
|
|
111
|
+
recast.types.visit(ast, {
|
|
112
|
+
visitImportDeclaration(pathNode) {
|
|
113
|
+
const source = pathNode.node.source && pathNode.node.source.value;
|
|
114
|
+
for (const spec of pathNode.node.specifiers || []) {
|
|
115
|
+
const local = spec.local && spec.local.name;
|
|
116
|
+
if (local) map[local] = source;
|
|
117
|
+
}
|
|
118
|
+
this.traverse(pathNode);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
return map;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function fileAlreadyEditable(code) {
|
|
125
|
+
return code.includes('data-preview-field-path') || code.includes('useSiteData') || code.includes('SiteDataProvider');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isDynamicExpression(expr, bindings) {
|
|
129
|
+
if (!expr) return true;
|
|
130
|
+
if (expr.type === 'JSXEmptyExpression') return false;
|
|
131
|
+
if (expr.type === 'StringLiteral' || expr.type === 'Literal' || expr.type === 'NumericLiteral' || expr.type === 'BooleanLiteral') {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
if (expr.type === 'TemplateLiteral' && (!expr.expressions || expr.expressions.length === 0)) return false;
|
|
135
|
+
if (expr.type === 'Identifier' && bindings.has(expr.name) && typeof bindings.get(expr.name) === 'string') {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
if (expr.type === 'Identifier' && bindings.has(expr.name) && bindings.get(expr.name)?.kind === 'array') {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveChildText(node, bindings) {
|
|
145
|
+
const direct = collectJsxText(node);
|
|
146
|
+
if (direct) return { text: direct, dynamic: false, fromBinding: false };
|
|
147
|
+
|
|
148
|
+
const children = node.children || [];
|
|
149
|
+
const texts = [];
|
|
150
|
+
let fromBinding = false;
|
|
151
|
+
for (const child of children) {
|
|
152
|
+
if (!child) continue;
|
|
153
|
+
if (child.type === 'JSXText') {
|
|
154
|
+
const value = normalizeText(child.value);
|
|
155
|
+
if (value) texts.push(value);
|
|
156
|
+
} else if (child.type === 'JSXExpressionContainer') {
|
|
157
|
+
const expr = child.expression;
|
|
158
|
+
if (!expr) continue;
|
|
159
|
+
if (expr.type === 'StringLiteral' || (expr.type === 'Literal' && typeof expr.value === 'string')) {
|
|
160
|
+
texts.push(String(expr.value));
|
|
161
|
+
} else if (expr.type === 'Identifier' && typeof bindings.get(expr.name) === 'string') {
|
|
162
|
+
texts.push(bindings.get(expr.name));
|
|
163
|
+
fromBinding = true;
|
|
164
|
+
} else if (expr.type === 'TemplateLiteral' && expr.expressions.length === 0) {
|
|
165
|
+
texts.push(expr.quasis.map((q) => q.value.cooked || '').join(''));
|
|
166
|
+
} else {
|
|
167
|
+
return { text: '', dynamic: true, fromBinding: false };
|
|
168
|
+
}
|
|
169
|
+
} else if (child.type === 'JSXElement') {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { text: normalizeText(texts.join(' ')), dynamic: false, fromBinding };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** True when the element renders literal text as a direct child. */
|
|
177
|
+
function hasDirectLiteralText(node) {
|
|
178
|
+
return (node.children || []).some(
|
|
179
|
+
(child) => child?.type === 'JSXText' && normalizeText(child.value).length > 2
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function hasEditableMarker(node) {
|
|
184
|
+
return hasJsxAttribute(node, 'data-preview-field-path') || hasJsxAttribute(node, 'data-preview-item-path');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function hasStaticMarker(node) {
|
|
188
|
+
return hasJsxAttribute(node, 'data-preview-static');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parentNames(pathNode) {
|
|
192
|
+
const names = [];
|
|
193
|
+
let current = pathNode.parent;
|
|
194
|
+
while (current) {
|
|
195
|
+
const node = current.node || current.value || current;
|
|
196
|
+
if (node && node.type === 'JSXElement') names.push(getJsxName(node));
|
|
197
|
+
current = current.parentPath || current.parent;
|
|
198
|
+
}
|
|
199
|
+
return names;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function inMapCallback(pathNode) {
|
|
203
|
+
let current = pathNode.parent;
|
|
204
|
+
while (current) {
|
|
205
|
+
const node = current.node || current.value || current;
|
|
206
|
+
if (node && node.type === 'CallExpression') {
|
|
207
|
+
const callee = node.callee;
|
|
208
|
+
if (callee && callee.type === 'MemberExpression' && !callee.computed && callee.property && callee.property.name === 'map') {
|
|
209
|
+
const callback = (node.arguments || [])[0];
|
|
210
|
+
const params = callback?.params || [];
|
|
211
|
+
return {
|
|
212
|
+
objectName: callee.object && callee.object.type === 'Identifier' ? callee.object.name : null,
|
|
213
|
+
call: node,
|
|
214
|
+
callback,
|
|
215
|
+
itemParam: params[0]?.type === 'Identifier' ? params[0].name : null,
|
|
216
|
+
indexParam: params[1]?.type === 'Identifier' ? params[1].name : null,
|
|
217
|
+
rootElement: callbackRootElement(callback),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
current = current.parentPath || current.parent;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The single JSX element a map callback returns. Collection contracts attach to
|
|
228
|
+
* this element, so ARC must emit exactly one candidate per map instead of one
|
|
229
|
+
* per descendant.
|
|
230
|
+
*/
|
|
231
|
+
function callbackRootElement(callback) {
|
|
232
|
+
if (!callback) return null;
|
|
233
|
+
const body = callback.body;
|
|
234
|
+
if (!body) return null;
|
|
235
|
+
if (body.type === 'JSXElement' || body.type === 'JSXFragment') return body;
|
|
236
|
+
if (body.type === 'ParenthesizedExpression') return callbackRootElement({ body: body.expression });
|
|
237
|
+
if (body.type === 'BlockStatement') {
|
|
238
|
+
for (const statement of body.body || []) {
|
|
239
|
+
if (statement.type === 'ReturnStatement' && statement.argument) {
|
|
240
|
+
const argument = statement.argument.type === 'ParenthesizedExpression'
|
|
241
|
+
? statement.argument.expression
|
|
242
|
+
: statement.argument;
|
|
243
|
+
if (argument.type === 'JSXElement' || argument.type === 'JSXFragment') return argument;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Finds which properties of the map item variable are rendered as visible text,
|
|
252
|
+
* image sources or link destinations, so the generated list schema mirrors the
|
|
253
|
+
* fields the component genuinely displays.
|
|
254
|
+
*/
|
|
255
|
+
function collectItemFieldUsage(callback, itemParam) {
|
|
256
|
+
const usage = new Map();
|
|
257
|
+
if (!callback || !itemParam) return usage;
|
|
258
|
+
|
|
259
|
+
function record(property, role) {
|
|
260
|
+
if (!property || usage.has(property)) return;
|
|
261
|
+
usage.set(property, role);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function memberProperty(expr) {
|
|
265
|
+
if (!expr || expr.type !== 'MemberExpression' || expr.computed) return null;
|
|
266
|
+
if (expr.object?.type !== 'Identifier' || expr.object.name !== itemParam) return null;
|
|
267
|
+
return expr.property?.name || null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
recast.types.visit(callback, {
|
|
271
|
+
visitJSXExpressionContainer(pathNode) {
|
|
272
|
+
const property = memberProperty(pathNode.node.expression);
|
|
273
|
+
if (property) {
|
|
274
|
+
const parent = pathNode.parent?.node || pathNode.parent?.value;
|
|
275
|
+
if (parent?.type === 'JSXAttribute') {
|
|
276
|
+
const attribute = parent.name?.name;
|
|
277
|
+
if (attribute === 'src') record(property, 'image');
|
|
278
|
+
else if (attribute === 'href') record(property, 'url');
|
|
279
|
+
else if (attribute === 'alt' || attribute === 'title') record(property, 'text');
|
|
280
|
+
} else {
|
|
281
|
+
record(property, 'text');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
this.traverse(pathNode);
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
return usage;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function classNameOf(node) {
|
|
292
|
+
return getJsxAttributeLiteral(node, 'className') || getJsxAttributeLiteral(node, 'class') || '';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function confidenceFor(kind, extras = {}) {
|
|
296
|
+
if (extras.already) return 1;
|
|
297
|
+
if (extras.dynamic) return 0.15;
|
|
298
|
+
if (extras.apiOwned) return 0.2;
|
|
299
|
+
if (extras.icon) return 0.1;
|
|
300
|
+
if (kind === 'url' && extras.action === 'whatsapp') return 0.96;
|
|
301
|
+
if (kind === 'url' && extras.social) return 0.93;
|
|
302
|
+
if (kind === 'split-action-contract') return 0.94;
|
|
303
|
+
if (kind === 'text' && HEADING_TAGS.has(extras.tag)) return 0.95;
|
|
304
|
+
if (kind === 'text' && extras.tag === 'p') return 0.9;
|
|
305
|
+
if (kind === 'image') return 0.88;
|
|
306
|
+
if (kind === 'alt') return 0.86;
|
|
307
|
+
if (kind === 'placeholder') return 0.84;
|
|
308
|
+
if (kind === 'text' && extras.tag === 'span') return extras.cta ? 0.82 : 0.7;
|
|
309
|
+
if (kind === 'text' && extras.tag === 'button') return 0.9;
|
|
310
|
+
if (kind === 'collection') return extras.staticCollection ? 0.86 : 0.35;
|
|
311
|
+
if (kind === 'text') return 0.78;
|
|
312
|
+
return 0.65;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function skipReasonForFile(relativeFile, code) {
|
|
316
|
+
if (/\.(stories|spec|test)\.(tsx|jsx|ts|js)$/.test(relativeFile)) return 'test-or-story-file';
|
|
317
|
+
if (/node_modules/.test(relativeFile)) return 'dependency';
|
|
318
|
+
if (code.includes('class-variance-authority') && code.includes('Slot') && !collectLooseText(code)) {
|
|
319
|
+
return 'primitive-ui';
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function collectLooseText(code) {
|
|
325
|
+
return /<(h[1-6]|p|Button|span)[^>]*>\s*[A-Za-z]/.test(code);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function analyzeFile({ code, relativeFile, profile, graph, ownerScope, componentMeta }) {
|
|
329
|
+
const adapters = activeAdapters(profile);
|
|
330
|
+
const fileSkip = skipReasonForFile(relativeFile, code);
|
|
331
|
+
if (fileSkip) {
|
|
332
|
+
return { candidates: [], skipped: true, reason: fileSkip, alreadyEditable: fileAlreadyEditable(code) };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let ast;
|
|
336
|
+
try {
|
|
337
|
+
ast = parseSource(code, relativeFile);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
return {
|
|
340
|
+
candidates: [],
|
|
341
|
+
skipped: true,
|
|
342
|
+
reason: 'parse-error',
|
|
343
|
+
error: err.message,
|
|
344
|
+
alreadyEditable: fileAlreadyEditable(code),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const bindings = collectStringBindings(ast);
|
|
349
|
+
const imports = collectImportMap(ast);
|
|
350
|
+
const candidates = [];
|
|
351
|
+
const usedLocs = new Set();
|
|
352
|
+
|
|
353
|
+
recast.types.visit(ast, {
|
|
354
|
+
visitJSXElement(pathNode) {
|
|
355
|
+
const node = pathNode.node;
|
|
356
|
+
const name = getJsxName(node);
|
|
357
|
+
const loc = locKey(node);
|
|
358
|
+
if (!loc || usedLocs.has(loc)) {
|
|
359
|
+
this.traverse(pathNode);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (SKIP_TAGS.has(name) || name === 'React.Fragment' || name === '') {
|
|
364
|
+
this.traverse(pathNode);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (hasEditableMarker(node)) {
|
|
369
|
+
candidates.push({
|
|
370
|
+
loc,
|
|
371
|
+
tag: name,
|
|
372
|
+
kind: 'already-editable',
|
|
373
|
+
confidence: 1,
|
|
374
|
+
skip: true,
|
|
375
|
+
reason: 'already-has-preview-binding',
|
|
376
|
+
file: relativeFile,
|
|
377
|
+
ownerScope,
|
|
378
|
+
});
|
|
379
|
+
this.traverse(pathNode);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const importSource = imports[name.split('.')[0]];
|
|
384
|
+
const recognition = recognizeWithAdapters(node, { profile, imports }, adapters);
|
|
385
|
+
const mapInfo = inMapCallback(pathNode);
|
|
386
|
+
let apiOwned = false;
|
|
387
|
+
if (mapInfo && mapInfo.objectName) {
|
|
388
|
+
const binding = bindings.get(mapInfo.objectName);
|
|
389
|
+
if (!binding || binding.kind !== 'array') apiOwned = true;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (DECORATIVE_TAGS.has(name) || isIconComponent(name, importSource)) {
|
|
393
|
+
candidates.push({
|
|
394
|
+
loc,
|
|
395
|
+
tag: name,
|
|
396
|
+
kind: 'decoration',
|
|
397
|
+
confidence: 0.1,
|
|
398
|
+
skip: true,
|
|
399
|
+
reason: 'decorative-icon',
|
|
400
|
+
file: relativeFile,
|
|
401
|
+
ownerScope,
|
|
402
|
+
fingerprint: fingerprintCandidate({ tag: name, kind: 'icon', importSource }),
|
|
403
|
+
});
|
|
404
|
+
this.traverse(pathNode);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const href = getJsxAttributeLiteral(node, 'href');
|
|
409
|
+
const src = getJsxAttributeLiteral(node, 'src');
|
|
410
|
+
const alt = getJsxAttributeLiteral(node, 'alt');
|
|
411
|
+
const placeholder = getJsxAttributeLiteral(node, 'placeholder');
|
|
412
|
+
const className = classNameOf(node);
|
|
413
|
+
const textInfo = resolveChildText(node, bindings);
|
|
414
|
+
const parents = parentNames(pathNode);
|
|
415
|
+
const actionNode = resolveActionWithAdapters(node, adapters) || (ACTION_TAGS.has(name) ? node : null);
|
|
416
|
+
|
|
417
|
+
const baseMeta = {
|
|
418
|
+
loc,
|
|
419
|
+
tag: name,
|
|
420
|
+
file: relativeFile,
|
|
421
|
+
ownerScope,
|
|
422
|
+
componentName: componentMeta?.name,
|
|
423
|
+
role: componentMeta?.role,
|
|
424
|
+
className,
|
|
425
|
+
parentName: parents[0],
|
|
426
|
+
recognition,
|
|
427
|
+
inMap: Boolean(mapInfo),
|
|
428
|
+
apiOwned,
|
|
429
|
+
fromBinding: textInfo.fromBinding,
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
if ((IMAGE_TAGS.has(name) || recognition?.kind === 'image') && src && !src.startsWith('{')) {
|
|
433
|
+
usedLocs.add(loc);
|
|
434
|
+
candidates.push({
|
|
435
|
+
...baseMeta,
|
|
436
|
+
kind: 'image',
|
|
437
|
+
operation: 'extract-image',
|
|
438
|
+
value: src,
|
|
439
|
+
extra: { alt },
|
|
440
|
+
confidence: confidenceFor('image', { dynamic: false }),
|
|
441
|
+
reason: 'literal-image-source',
|
|
442
|
+
fingerprint: fingerprintCandidate({ tag: name, kind: 'image', hasAlt: Boolean(alt) }),
|
|
443
|
+
});
|
|
444
|
+
if (alt && !isStaticSkipText(alt)) {
|
|
445
|
+
candidates.push({
|
|
446
|
+
...baseMeta,
|
|
447
|
+
kind: 'alt',
|
|
448
|
+
operation: 'extract-alt',
|
|
449
|
+
value: alt,
|
|
450
|
+
confidence: confidenceFor('alt'),
|
|
451
|
+
reason: 'literal-image-alt',
|
|
452
|
+
fingerprint: fingerprintCandidate({ tag: name, kind: 'alt' }),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
this.traverse(pathNode);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (placeholder && !isStaticSkipText(placeholder)) {
|
|
460
|
+
usedLocs.add(loc);
|
|
461
|
+
candidates.push({
|
|
462
|
+
...baseMeta,
|
|
463
|
+
kind: 'placeholder',
|
|
464
|
+
operation: 'extract-placeholder',
|
|
465
|
+
value: placeholder,
|
|
466
|
+
confidence: confidenceFor('placeholder'),
|
|
467
|
+
reason: 'literal-placeholder',
|
|
468
|
+
fingerprint: fingerprintCandidate({ tag: name, kind: 'placeholder' }),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const actionableHref = href || (name === 'Button' ? getJsxAttributeLiteral(node, 'href') : null);
|
|
473
|
+
const isAction = Boolean(actionableHref) && ACTION_TAGS.has(name);
|
|
474
|
+
if (isAction && actionableHref) {
|
|
475
|
+
const action = classifyHref(actionableHref);
|
|
476
|
+
const innerText = textInfo.dynamic ? '' : textInfo.text;
|
|
477
|
+
const looksCta = isLikelyCtaClass(className) || ['whatsapp', 'phone', 'email'].includes(action) || Boolean(innerText);
|
|
478
|
+
if (looksCta && innerText && !textInfo.dynamic && !apiOwned) {
|
|
479
|
+
usedLocs.add(loc);
|
|
480
|
+
candidates.push({
|
|
481
|
+
...baseMeta,
|
|
482
|
+
kind: 'split-action-contract',
|
|
483
|
+
operation: 'split-action-contract',
|
|
484
|
+
value: actionableHref,
|
|
485
|
+
label: innerText,
|
|
486
|
+
extra: {
|
|
487
|
+
action,
|
|
488
|
+
social: !['whatsapp', 'phone', 'email', 'link'].includes(action),
|
|
489
|
+
platform: ['instagram', 'facebook', 'tiktok', 'twitter', 'youtube', 'linkedin', 'pinterest'].includes(action) ? action : undefined,
|
|
490
|
+
},
|
|
491
|
+
confidence: confidenceFor('split-action-contract', { action, social: action !== 'link' }),
|
|
492
|
+
reason: `interactive-${action}-requires-split-contract`,
|
|
493
|
+
fingerprint: fingerprintCandidate({ tag: name, kind: 'action', action, childCount: (node.children || []).length }),
|
|
494
|
+
});
|
|
495
|
+
this.traverse(pathNode);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (action !== 'link' && !innerText) {
|
|
500
|
+
usedLocs.add(loc);
|
|
501
|
+
candidates.push({
|
|
502
|
+
...baseMeta,
|
|
503
|
+
kind: 'url',
|
|
504
|
+
operation: 'extract-url',
|
|
505
|
+
value: actionableHref,
|
|
506
|
+
extra: { action, platform: action },
|
|
507
|
+
confidence: confidenceFor('url', { action, social: true }),
|
|
508
|
+
reason: `literal-${action}-url`,
|
|
509
|
+
fingerprint: fingerprintCandidate({ tag: name, kind: 'url', action }),
|
|
510
|
+
});
|
|
511
|
+
this.traverse(pathNode);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const headingLike = HEADING_TAGS.has(name) || (recognition && recognition.kind === 'text' && HEADING_TAGS.has(recognition.tag || name));
|
|
517
|
+
const textLike = TEXT_TAGS.has(name) || headingLike || name === 'Button' || name === 'button' || (recognition && recognition.kind === 'text');
|
|
518
|
+
if (textLike && !isAction) {
|
|
519
|
+
if (textInfo.dynamic || apiOwned) {
|
|
520
|
+
candidates.push({
|
|
521
|
+
...baseMeta,
|
|
522
|
+
kind: 'text',
|
|
523
|
+
operation: 'skip-dynamic',
|
|
524
|
+
skip: true,
|
|
525
|
+
confidence: confidenceFor('text', { dynamic: textInfo.dynamic, apiOwned }),
|
|
526
|
+
reason: apiOwned ? 'existing-dynamic-or-api-data' : 'non-literal-expression',
|
|
527
|
+
});
|
|
528
|
+
this.traverse(pathNode);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (!isStaticSkipText(textInfo.text)) {
|
|
532
|
+
usedLocs.add(loc);
|
|
533
|
+
const kindTag = headingLike ? (name.startsWith('h') ? name : 'h1') : name;
|
|
534
|
+
candidates.push({
|
|
535
|
+
...baseMeta,
|
|
536
|
+
kind: 'text',
|
|
537
|
+
operation: 'extract-text',
|
|
538
|
+
value: textInfo.text,
|
|
539
|
+
extra: { tag: kindTag, cta: name === 'Button' || name === 'button' },
|
|
540
|
+
confidence: confidenceFor('text', { tag: kindTag, cta: name === 'Button' || name === 'button' }),
|
|
541
|
+
reason: headingLike ? 'semantic-heading' : `literal-${name}-text`,
|
|
542
|
+
fingerprint: fingerprintCandidate({ tag: name, kind: 'text', heading: headingLike }),
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Fivora refuses data-preview-field-path on broad containers, but strict
|
|
548
|
+
// mode still requires every visible string to be covered. Literal text
|
|
549
|
+
// sitting directly in a <div>/<section> is therefore wrapped in a span
|
|
550
|
+
// that owns the contract, which is layout-neutral for inline content.
|
|
551
|
+
if (
|
|
552
|
+
!textLike &&
|
|
553
|
+
!isAction &&
|
|
554
|
+
BROAD_CONTENT_CONTAINERS.has(name) &&
|
|
555
|
+
!textInfo.dynamic &&
|
|
556
|
+
!apiOwned &&
|
|
557
|
+
textInfo.text &&
|
|
558
|
+
!isStaticSkipText(textInfo.text) &&
|
|
559
|
+
hasDirectLiteralText(node)
|
|
560
|
+
) {
|
|
561
|
+
usedLocs.add(loc);
|
|
562
|
+
candidates.push({
|
|
563
|
+
...baseMeta,
|
|
564
|
+
kind: 'text',
|
|
565
|
+
operation: 'wrap-text-span',
|
|
566
|
+
value: textInfo.text,
|
|
567
|
+
extra: { tag: name, wrapped: true },
|
|
568
|
+
confidence: 0.86,
|
|
569
|
+
reason: `literal-text-in-${name}-container`,
|
|
570
|
+
fingerprint: fingerprintCandidate({ tag: name, kind: 'text', wrapped: true }),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// One contract per collection, anchored on the element the map returns.
|
|
575
|
+
if (
|
|
576
|
+
mapInfo &&
|
|
577
|
+
mapInfo.objectName &&
|
|
578
|
+
mapInfo.rootElement === node &&
|
|
579
|
+
bindings.get(mapInfo.objectName)?.kind === 'array'
|
|
580
|
+
) {
|
|
581
|
+
const arr = bindings.get(mapInfo.objectName);
|
|
582
|
+
const itemUsage = collectItemFieldUsage(mapInfo.callback, mapInfo.itemParam);
|
|
583
|
+
const objectItems = arr.items.every((item) => item.type === 'object');
|
|
584
|
+
const boundProperties = [...itemUsage.keys()];
|
|
585
|
+
const convertible =
|
|
586
|
+
objectItems &&
|
|
587
|
+
boundProperties.length > 0 &&
|
|
588
|
+
arr.items.every((item) => boundProperties.every((key) => key in item.value));
|
|
589
|
+
|
|
590
|
+
candidates.push({
|
|
591
|
+
...baseMeta,
|
|
592
|
+
kind: 'collection',
|
|
593
|
+
operation: 'collection-conversion',
|
|
594
|
+
value: arr.items,
|
|
595
|
+
extra: {
|
|
596
|
+
staticCollection: true,
|
|
597
|
+
binding: mapInfo.objectName,
|
|
598
|
+
itemParam: mapInfo.itemParam,
|
|
599
|
+
indexParam: mapInfo.indexParam,
|
|
600
|
+
itemFields: [...itemUsage.entries()].map(([key, role]) => ({ key, role })),
|
|
601
|
+
objectItems,
|
|
602
|
+
},
|
|
603
|
+
confidence: convertible
|
|
604
|
+
? confidenceFor('collection', { staticCollection: true })
|
|
605
|
+
: 0.3,
|
|
606
|
+
reason: convertible ? 'static-array-map' : 'collection-shape-not-uniform',
|
|
607
|
+
fingerprint: fingerprintCandidate({ tag: name, kind: 'collection', size: arr.items.length }),
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
this.traverse(pathNode);
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
ast,
|
|
617
|
+
candidates,
|
|
618
|
+
skipped: false,
|
|
619
|
+
alreadyEditable: fileAlreadyEditable(code),
|
|
620
|
+
bindings: Object.fromEntries([...bindings.entries()].filter(([, v]) => typeof v === 'string')),
|
|
621
|
+
imports,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function collectDesignSnapshot(code) {
|
|
626
|
+
const classNames = [];
|
|
627
|
+
const styles = [];
|
|
628
|
+
recast.types.visit(parseSource(code, 'snapshot.tsx'), {
|
|
629
|
+
visitJSXAttribute(pathNode) {
|
|
630
|
+
const node = pathNode.node;
|
|
631
|
+
const name = node.name && node.name.name;
|
|
632
|
+
if (name === 'className' || name === 'class') {
|
|
633
|
+
classNames.push(recast.print(node).code);
|
|
634
|
+
}
|
|
635
|
+
if (name === 'style') {
|
|
636
|
+
styles.push(recast.print(node).code);
|
|
637
|
+
}
|
|
638
|
+
this.traverse(pathNode);
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
return { classNames, styles };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
module.exports = {
|
|
645
|
+
analyzeFile,
|
|
646
|
+
collectDesignSnapshot,
|
|
647
|
+
collectStringBindings,
|
|
648
|
+
fingerprintCandidate,
|
|
649
|
+
normalizeText,
|
|
650
|
+
isStaticSkipText,
|
|
651
|
+
};
|