@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,279 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fivora hosts storefronts as static exports served from merchant sub-paths.
|
|
5
|
+
* A converted project therefore needs `output: 'export'`, unoptimized images
|
|
6
|
+
* and a NEXT_PUBLIC_SITE_BASE_PATH-driven basePath, or `deneb package` fails
|
|
7
|
+
* its sandbox build. ARC configures this via AST so an existing config keeps
|
|
8
|
+
* its plugins, comments and formatting.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const recast = require('recast');
|
|
14
|
+
const { parseSource, printSource, b } = require('./ast.cjs');
|
|
15
|
+
|
|
16
|
+
const CONFIG_FILENAMES = [
|
|
17
|
+
'next.config.ts',
|
|
18
|
+
'next.config.mjs',
|
|
19
|
+
'next.config.js',
|
|
20
|
+
'next.config.cjs',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const BASE_PATH_ENV = 'NEXT_PUBLIC_SITE_BASE_PATH';
|
|
24
|
+
|
|
25
|
+
function findNextConfig(projectDir) {
|
|
26
|
+
for (const name of CONFIG_FILENAMES) {
|
|
27
|
+
const abs = path.join(projectDir, name);
|
|
28
|
+
if (fs.existsSync(abs)) return { abs, name };
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createNextConfig(projectDir, isTypeScript) {
|
|
34
|
+
const name = isTypeScript ? 'next.config.ts' : 'next.config.mjs';
|
|
35
|
+
const abs = path.join(projectDir, name);
|
|
36
|
+
const typed = isTypeScript
|
|
37
|
+
? `import type { NextConfig } from 'next';\n\n`
|
|
38
|
+
: '';
|
|
39
|
+
const annotation = isTypeScript ? ': NextConfig' : '';
|
|
40
|
+
|
|
41
|
+
const code = `${typed}const basePath = process.env.${BASE_PATH_ENV} || '';
|
|
42
|
+
|
|
43
|
+
const nextConfig${annotation} = {
|
|
44
|
+
output: 'export',
|
|
45
|
+
basePath: basePath || undefined,
|
|
46
|
+
assetPrefix: basePath ? \`\${basePath}/\` : undefined,
|
|
47
|
+
images: {
|
|
48
|
+
unoptimized: true,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export default nextConfig;
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
fs.writeFileSync(abs, code, 'utf8');
|
|
56
|
+
return { file: name, created: true, updated: true, warnings: [] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Locates the object literal that describes the Next.js config. */
|
|
60
|
+
function findConfigObject(ast) {
|
|
61
|
+
let found = null;
|
|
62
|
+
|
|
63
|
+
recast.types.visit(ast, {
|
|
64
|
+
visitExportDefaultDeclaration(pathNode) {
|
|
65
|
+
const declaration = pathNode.node.declaration;
|
|
66
|
+
if (declaration?.type === 'ObjectExpression') {
|
|
67
|
+
found = declaration;
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
this.traverse(pathNode);
|
|
71
|
+
},
|
|
72
|
+
visitAssignmentExpression(pathNode) {
|
|
73
|
+
const node = pathNode.node;
|
|
74
|
+
const left = recast.print(node.left).code;
|
|
75
|
+
if (!found && /^module\.exports$/.test(left) && node.right?.type === 'ObjectExpression') {
|
|
76
|
+
found = node.right;
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
this.traverse(pathNode);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (found) return found;
|
|
84
|
+
|
|
85
|
+
// `const nextConfig = {...}` possibly wrapped by a plugin on export.
|
|
86
|
+
recast.types.visit(ast, {
|
|
87
|
+
visitVariableDeclarator(pathNode) {
|
|
88
|
+
const node = pathNode.node;
|
|
89
|
+
if (
|
|
90
|
+
!found &&
|
|
91
|
+
node.id?.type === 'Identifier' &&
|
|
92
|
+
/config/i.test(node.id.name) &&
|
|
93
|
+
node.init?.type === 'ObjectExpression'
|
|
94
|
+
) {
|
|
95
|
+
found = node.init;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
this.traverse(pathNode);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return found;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function hasProperty(objectExpression, key) {
|
|
106
|
+
return (objectExpression.properties || []).some(
|
|
107
|
+
(property) =>
|
|
108
|
+
(property.type === 'ObjectProperty' || property.type === 'Property') &&
|
|
109
|
+
(property.key?.name === key || property.key?.value === key)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function propertyValue(objectExpression, key) {
|
|
114
|
+
const property = (objectExpression.properties || []).find(
|
|
115
|
+
(candidate) =>
|
|
116
|
+
(candidate.type === 'ObjectProperty' || candidate.type === 'Property') &&
|
|
117
|
+
(candidate.key?.name === key || candidate.key?.value === key)
|
|
118
|
+
);
|
|
119
|
+
return property ? property.value : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function hasBasePathDeclaration(ast) {
|
|
123
|
+
let declared = false;
|
|
124
|
+
recast.types.visit(ast, {
|
|
125
|
+
visitVariableDeclarator(pathNode) {
|
|
126
|
+
if (pathNode.node.id?.type === 'Identifier' && pathNode.node.id.name === 'basePath') {
|
|
127
|
+
declared = true;
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
this.traverse(pathNode);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
return declared;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function insertBasePathDeclaration(ast) {
|
|
137
|
+
const program = ast.program || ast;
|
|
138
|
+
const declaration = b.variableDeclaration('const', [
|
|
139
|
+
b.variableDeclarator(
|
|
140
|
+
b.identifier('basePath'),
|
|
141
|
+
b.logicalExpression(
|
|
142
|
+
'||',
|
|
143
|
+
b.memberExpression(
|
|
144
|
+
b.memberExpression(b.identifier('process'), b.identifier('env')),
|
|
145
|
+
b.identifier(BASE_PATH_ENV)
|
|
146
|
+
),
|
|
147
|
+
b.stringLiteral('')
|
|
148
|
+
)
|
|
149
|
+
),
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
const body = program.body || [];
|
|
153
|
+
let insertAt = 0;
|
|
154
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
155
|
+
const node = body[index];
|
|
156
|
+
const isDirective =
|
|
157
|
+
node.type === 'ExpressionStatement' &&
|
|
158
|
+
(node.expression?.type === 'StringLiteral' || node.expression?.type === 'Literal');
|
|
159
|
+
if (node.type === 'ImportDeclaration' || isDirective) insertAt = index + 1;
|
|
160
|
+
else break;
|
|
161
|
+
}
|
|
162
|
+
body.splice(insertAt, 0, declaration);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Adds the static-export settings a converted project needs, leaving any
|
|
167
|
+
* setting the developer already chose untouched.
|
|
168
|
+
*/
|
|
169
|
+
function ensureStaticExportConfig(projectDir, profile) {
|
|
170
|
+
const existing = findNextConfig(projectDir);
|
|
171
|
+
if (!existing) {
|
|
172
|
+
return createNextConfig(projectDir, profile?.language !== 'javascript');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const original = fs.readFileSync(existing.abs, 'utf8');
|
|
176
|
+
let ast;
|
|
177
|
+
try {
|
|
178
|
+
ast = parseSource(original, existing.name);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return {
|
|
181
|
+
file: existing.name,
|
|
182
|
+
created: false,
|
|
183
|
+
updated: false,
|
|
184
|
+
warnings: [
|
|
185
|
+
`${existing.name} could not be parsed (${err.message}). Add output: 'export', images.unoptimized and a ${BASE_PATH_ENV} basePath manually.`,
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const configObject = findConfigObject(ast);
|
|
191
|
+
if (!configObject) {
|
|
192
|
+
return {
|
|
193
|
+
file: existing.name,
|
|
194
|
+
created: false,
|
|
195
|
+
updated: false,
|
|
196
|
+
warnings: [
|
|
197
|
+
`${existing.name} does not expose a plain config object, so ARC left it unchanged. Add output: 'export', images.unoptimized and a ${BASE_PATH_ENV} basePath manually.`,
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const warnings = [];
|
|
203
|
+
let updated = false;
|
|
204
|
+
|
|
205
|
+
if (!hasProperty(configObject, 'output')) {
|
|
206
|
+
configObject.properties.push(
|
|
207
|
+
b.objectProperty(b.identifier('output'), b.stringLiteral('export'))
|
|
208
|
+
);
|
|
209
|
+
updated = true;
|
|
210
|
+
} else {
|
|
211
|
+
const value = propertyValue(configObject, 'output');
|
|
212
|
+
if (value?.value !== 'export') {
|
|
213
|
+
warnings.push(
|
|
214
|
+
`${existing.name} sets output: '${value?.value}'. Fivora requires output: 'export'; ARC left your value in place.`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!hasProperty(configObject, 'images')) {
|
|
220
|
+
configObject.properties.push(
|
|
221
|
+
b.objectProperty(
|
|
222
|
+
b.identifier('images'),
|
|
223
|
+
b.objectExpression([
|
|
224
|
+
b.objectProperty(b.identifier('unoptimized'), b.booleanLiteral(true)),
|
|
225
|
+
])
|
|
226
|
+
)
|
|
227
|
+
);
|
|
228
|
+
updated = true;
|
|
229
|
+
} else {
|
|
230
|
+
const images = propertyValue(configObject, 'images');
|
|
231
|
+
if (images?.type === 'ObjectExpression' && !hasProperty(images, 'unoptimized')) {
|
|
232
|
+
images.properties.push(
|
|
233
|
+
b.objectProperty(b.identifier('unoptimized'), b.booleanLiteral(true))
|
|
234
|
+
);
|
|
235
|
+
updated = true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!hasProperty(configObject, 'basePath')) {
|
|
240
|
+
if (!hasBasePathDeclaration(ast)) insertBasePathDeclaration(ast);
|
|
241
|
+
configObject.properties.push(
|
|
242
|
+
b.objectProperty(
|
|
243
|
+
b.identifier('basePath'),
|
|
244
|
+
b.logicalExpression('||', b.identifier('basePath'), b.identifier('undefined'))
|
|
245
|
+
)
|
|
246
|
+
);
|
|
247
|
+
configObject.properties.push(
|
|
248
|
+
b.objectProperty(
|
|
249
|
+
b.identifier('assetPrefix'),
|
|
250
|
+
b.conditionalExpression(
|
|
251
|
+
b.identifier('basePath'),
|
|
252
|
+
b.templateLiteral(
|
|
253
|
+
[
|
|
254
|
+
b.templateElement({ raw: '', cooked: '' }, false),
|
|
255
|
+
b.templateElement({ raw: '/', cooked: '/' }, true),
|
|
256
|
+
],
|
|
257
|
+
[b.identifier('basePath')]
|
|
258
|
+
),
|
|
259
|
+
b.identifier('undefined')
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
);
|
|
263
|
+
updated = true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!updated) {
|
|
267
|
+
return { file: existing.name, created: false, updated: false, warnings };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const code = printSource(ast, original);
|
|
271
|
+
fs.writeFileSync(existing.abs, code, 'utf8');
|
|
272
|
+
return { file: existing.name, created: false, updated: true, warnings };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = {
|
|
276
|
+
ensureStaticExportConfig,
|
|
277
|
+
findNextConfig,
|
|
278
|
+
BASE_PATH_ENV,
|
|
279
|
+
};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { CONFIDENCE } = require('./version.cjs');
|
|
4
|
+
const { inferSection, inferFieldName, buildFieldPath, classifyFieldType, uniquePath } = require('./field-paths.cjs');
|
|
5
|
+
const { classifyHref } = require('./adapters.cjs');
|
|
6
|
+
|
|
7
|
+
function recipeBoost(candidate, recipe) {
|
|
8
|
+
if (!recipe) return 0;
|
|
9
|
+
let boost = 0;
|
|
10
|
+
if (recipe.actionRules?.splitActionAndLabel && candidate.operation === 'split-action-contract') boost += 0.03;
|
|
11
|
+
const keywords = recipe.signatures?.keywords || [];
|
|
12
|
+
const hay = `${candidate.tag} ${candidate.value || ''} ${candidate.label || ''} ${candidate.file || ''}`.toLowerCase();
|
|
13
|
+
if (keywords.some((kw) => hay.includes(String(kw).toLowerCase()))) boost += 0.02;
|
|
14
|
+
return Math.min(boost, 0.08);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function decideThreshold(confidence, candidate) {
|
|
18
|
+
if (candidate.skip) return 'skip';
|
|
19
|
+
if (confidence >= CONFIDENCE.AUTO) return 'auto';
|
|
20
|
+
if (confidence >= CONFIDENCE.VALIDATE) return 'validate';
|
|
21
|
+
return 'skip';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function planTransformations({ profile, analyses, recipe }) {
|
|
25
|
+
const usedPaths = new Set();
|
|
26
|
+
const filePlans = [];
|
|
27
|
+
const skipped = [];
|
|
28
|
+
const explanations = [];
|
|
29
|
+
|
|
30
|
+
for (const analysis of analyses) {
|
|
31
|
+
const transformations = [];
|
|
32
|
+
for (const candidate of analysis.candidates || []) {
|
|
33
|
+
const confidence = Math.min(0.99, (candidate.confidence || 0) + recipeBoost(candidate, recipe));
|
|
34
|
+
const decision = decideThreshold(confidence, candidate);
|
|
35
|
+
const section = inferSection({
|
|
36
|
+
componentName: candidate.componentName,
|
|
37
|
+
fileName: candidate.file,
|
|
38
|
+
className: candidate.className,
|
|
39
|
+
parentName: candidate.parentName,
|
|
40
|
+
tag: candidate.tag,
|
|
41
|
+
role: candidate.role,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let scope = candidate.ownerScope || 'home';
|
|
45
|
+
if (candidate.extra && ['instagram', 'facebook', 'tiktok', 'twitter', 'youtube', 'linkedin', 'pinterest'].includes(candidate.extra.action || candidate.extra.platform)) {
|
|
46
|
+
scope = 'common';
|
|
47
|
+
}
|
|
48
|
+
if (section === 'header' || section === 'footer' || section === 'navigation' || section === 'announcement') {
|
|
49
|
+
scope = 'common';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const extra = { ...(candidate.extra || {}) };
|
|
53
|
+
if (candidate.operation === 'split-action-contract') {
|
|
54
|
+
extra.action = extra.action || classifyHref(candidate.value);
|
|
55
|
+
extra.paired = true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let transform = {
|
|
59
|
+
loc: candidate.loc,
|
|
60
|
+
operation: candidate.operation || candidate.kind,
|
|
61
|
+
tag: candidate.tag,
|
|
62
|
+
file: candidate.file,
|
|
63
|
+
confidence,
|
|
64
|
+
decision,
|
|
65
|
+
reason: candidate.reason,
|
|
66
|
+
recipeId: recipe?.name || recipe?.id || null,
|
|
67
|
+
fingerprint: candidate.fingerprint,
|
|
68
|
+
fallback: candidate.value,
|
|
69
|
+
labelFallback: candidate.label,
|
|
70
|
+
fieldType: classifyFieldType(candidate.kind, candidate.value || candidate.label),
|
|
71
|
+
section,
|
|
72
|
+
scope,
|
|
73
|
+
explain: {
|
|
74
|
+
detected: `${candidate.tag} ${candidate.kind}`,
|
|
75
|
+
why: candidate.reason,
|
|
76
|
+
recipe: recipe?.name || null,
|
|
77
|
+
confidence,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (decision === 'skip' || candidate.skip || candidate.kind === 'already-editable' || candidate.kind === 'decoration') {
|
|
82
|
+
skipped.push({
|
|
83
|
+
file: candidate.file,
|
|
84
|
+
loc: candidate.loc,
|
|
85
|
+
reason: candidate.reason || 'low-confidence',
|
|
86
|
+
confidence,
|
|
87
|
+
kind: candidate.kind,
|
|
88
|
+
});
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (candidate.operation === 'split-action-contract') {
|
|
93
|
+
const urlName = inferFieldName('url', candidate.tag, candidate.label, extra);
|
|
94
|
+
const labelName = inferFieldName('label', candidate.tag, candidate.label, { ...extra, paired: true });
|
|
95
|
+
const actionSection = sectionForAction(scope, section, extra);
|
|
96
|
+
transform.urlField = buildFieldPath({ scope, section: actionSection, field: urlName, used: usedPaths });
|
|
97
|
+
const sibling = transform.urlField.split('.');
|
|
98
|
+
sibling[sibling.length - 1] = labelName;
|
|
99
|
+
transform.labelField = uniquePath(usedPaths, sibling.join('.'));
|
|
100
|
+
transform.fieldType = 'url';
|
|
101
|
+
transform.labelFieldType = 'text';
|
|
102
|
+
} else if (candidate.operation === 'extract-url') {
|
|
103
|
+
transform.field = buildFieldPath({
|
|
104
|
+
scope,
|
|
105
|
+
section: sectionForAction(scope, section, extra),
|
|
106
|
+
field: inferFieldName('url', candidate.tag, candidate.value, extra),
|
|
107
|
+
used: usedPaths,
|
|
108
|
+
});
|
|
109
|
+
transform.fieldType = 'url';
|
|
110
|
+
} else if (candidate.operation === 'extract-image') {
|
|
111
|
+
transform.field = buildFieldPath({
|
|
112
|
+
scope,
|
|
113
|
+
section,
|
|
114
|
+
field: inferFieldName('image', candidate.tag, extra.alt, extra),
|
|
115
|
+
used: usedPaths,
|
|
116
|
+
});
|
|
117
|
+
transform.fieldType = 'image';
|
|
118
|
+
} else if (candidate.operation === 'extract-alt') {
|
|
119
|
+
transform.field = buildFieldPath({
|
|
120
|
+
scope,
|
|
121
|
+
section,
|
|
122
|
+
field: inferFieldName('alt', candidate.tag, candidate.value, extra),
|
|
123
|
+
used: usedPaths,
|
|
124
|
+
});
|
|
125
|
+
transform.fieldType = 'text';
|
|
126
|
+
} else if (candidate.operation === 'wrap-text-span') {
|
|
127
|
+
transform.field = buildFieldPath({
|
|
128
|
+
scope,
|
|
129
|
+
section,
|
|
130
|
+
field: inferFieldName('text', candidate.tag, candidate.value, extra),
|
|
131
|
+
used: usedPaths,
|
|
132
|
+
});
|
|
133
|
+
transform.fieldType = 'text';
|
|
134
|
+
} else if (candidate.operation === 'extract-placeholder') {
|
|
135
|
+
transform.field = buildFieldPath({
|
|
136
|
+
scope,
|
|
137
|
+
section,
|
|
138
|
+
field: inferFieldName('placeholder', candidate.tag, candidate.value, extra),
|
|
139
|
+
used: usedPaths,
|
|
140
|
+
});
|
|
141
|
+
} else if (candidate.operation === 'collection-conversion') {
|
|
142
|
+
// A collection is named after the developer's own array variable so the
|
|
143
|
+
// merchant sees "products", not "items2".
|
|
144
|
+
transform.listField = uniquePath(usedPaths, `${scope}.${extra.binding || 'items'}`);
|
|
145
|
+
transform.field = undefined;
|
|
146
|
+
transform.fieldType = 'list';
|
|
147
|
+
transform.itemFields = (extra.itemFields || []).map((field) => ({
|
|
148
|
+
key: field.key,
|
|
149
|
+
type: field.role === 'image' ? 'image' : field.role === 'url' ? 'url' : 'text',
|
|
150
|
+
}));
|
|
151
|
+
transform.items = candidate.value.map((item) => item.value);
|
|
152
|
+
transform.itemParam = extra.itemParam;
|
|
153
|
+
transform.indexParam = extra.indexParam;
|
|
154
|
+
if (!transform.itemFields.length || !extra.objectItems) transform.decision = 'skip';
|
|
155
|
+
} else {
|
|
156
|
+
transform.field = buildFieldPath({
|
|
157
|
+
scope,
|
|
158
|
+
section,
|
|
159
|
+
field: inferFieldName(candidate.kind, extra.tag || candidate.tag, candidate.value, extra),
|
|
160
|
+
used: usedPaths,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (transform.decision === 'skip') {
|
|
165
|
+
skipped.push({
|
|
166
|
+
file: candidate.file,
|
|
167
|
+
loc: candidate.loc,
|
|
168
|
+
reason: 'collection-not-safe',
|
|
169
|
+
confidence,
|
|
170
|
+
kind: candidate.kind,
|
|
171
|
+
});
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
transformations.push(transform);
|
|
176
|
+
explanations.push(transform.explain);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
filePlans.push({
|
|
180
|
+
file: analysis.relativeFile,
|
|
181
|
+
alreadyEditable: analysis.alreadyEditable,
|
|
182
|
+
parseError: analysis.reason === 'parse-error' ? analysis.error : null,
|
|
183
|
+
skippedFile: analysis.skipped,
|
|
184
|
+
skipReason: analysis.skipped ? analysis.reason : null,
|
|
185
|
+
transformations,
|
|
186
|
+
originalCode: analysis.code,
|
|
187
|
+
designSnapshot: analysis.designSnapshot,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
files: filePlans,
|
|
193
|
+
skipped,
|
|
194
|
+
explanations,
|
|
195
|
+
usedPaths: [...usedPaths],
|
|
196
|
+
stats: summarizePlan(filePlans, skipped),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function sectionForAction(scope, section, extra) {
|
|
201
|
+
if (scope === 'common' && (extra.social || ['instagram', 'facebook', 'twitter', 'tiktok', 'youtube', 'linkedin'].includes(extra.action))) {
|
|
202
|
+
return 'footer';
|
|
203
|
+
}
|
|
204
|
+
if (extra.action === 'whatsapp' || extra.action === 'phone' || extra.action === 'email') {
|
|
205
|
+
return section || 'contact';
|
|
206
|
+
}
|
|
207
|
+
return section;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function summarizePlan(filePlans, skipped) {
|
|
211
|
+
const planned = filePlans.reduce((n, f) => n + f.transformations.length, 0);
|
|
212
|
+
const auto = filePlans.reduce((n, f) => n + f.transformations.filter((t) => t.decision === 'auto').length, 0);
|
|
213
|
+
const validate = filePlans.reduce((n, f) => n + f.transformations.filter((t) => t.decision === 'validate').length, 0);
|
|
214
|
+
const filesAffected = filePlans.filter((f) => f.transformations.length > 0).length;
|
|
215
|
+
return {
|
|
216
|
+
planned,
|
|
217
|
+
auto,
|
|
218
|
+
validate,
|
|
219
|
+
skipped: skipped.length,
|
|
220
|
+
filesAffected,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = {
|
|
225
|
+
planTransformations,
|
|
226
|
+
decideThreshold,
|
|
227
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { ARC_NAME, ARC_FULL_NAME, ARC_VERSION } = require('./version.cjs');
|
|
4
|
+
|
|
5
|
+
const C = {
|
|
6
|
+
reset: '\x1b[0m',
|
|
7
|
+
bold: '\x1b[1m',
|
|
8
|
+
dim: '\x1b[90m',
|
|
9
|
+
green: '\x1b[32m',
|
|
10
|
+
yellow: '\x1b[33m',
|
|
11
|
+
cyan: '\x1b[36m',
|
|
12
|
+
red: '\x1b[31m',
|
|
13
|
+
white: '\x1b[37m',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function ok(msg) {
|
|
17
|
+
console.log(` ${C.green}✓${C.reset} ${msg}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function warn(msg) {
|
|
21
|
+
console.log(` ${C.yellow}⚠${C.reset} ${msg}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function info(msg) {
|
|
25
|
+
console.log(` ${C.cyan}•${C.reset} ${msg}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function heading(title) {
|
|
29
|
+
console.log(`\n${C.bold}${title}${C.reset}\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function printBanner(mode) {
|
|
33
|
+
const suffix = mode === 'dry-run' ? ' (dry run)' : mode === 'explain' ? ' (explain)' : '';
|
|
34
|
+
console.log(`\n${C.cyan}▲${C.reset} ${C.bold}${ARC_NAME}${C.reset}${suffix}`);
|
|
35
|
+
console.log(`${C.dim}${ARC_FULL_NAME} v${ARC_VERSION}${C.reset}\n`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function printProfile(profile) {
|
|
39
|
+
heading('Analyzing project...');
|
|
40
|
+
const fw = profile.framework === 'nextjs'
|
|
41
|
+
? `Next.js${profile.frameworkVersion ? ' ' + profile.frameworkVersion : ''}`
|
|
42
|
+
: profile.framework;
|
|
43
|
+
ok(`${fw} detected`);
|
|
44
|
+
ok(`${profile.language === 'javascript' ? 'JavaScript' : profile.language === 'typescript' ? 'TypeScript' : 'Mixed JS/TS'} detected`);
|
|
45
|
+
if (profile.cssSystems.includes('tailwind-v4')) ok('Tailwind CSS v4 detected');
|
|
46
|
+
else if (profile.cssSystems.some((s) => s.startsWith('tailwind'))) ok('Tailwind CSS detected');
|
|
47
|
+
if (profile.shadcn) ok('shadcn/ui detected');
|
|
48
|
+
if (profile.heroui) ok('HeroUI detected');
|
|
49
|
+
if (profile.reactBits) ok('React Bits detected');
|
|
50
|
+
if (profile.router === 'next-app') ok('App Router detected');
|
|
51
|
+
if (profile.router === 'next-pages') ok('Pages Router detected');
|
|
52
|
+
if (profile.router === 'react-router') ok('React Router detected');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function printScan(profile, graph, candidateCount, actionCount) {
|
|
56
|
+
heading('Scanning architecture...');
|
|
57
|
+
ok(`${profile.routes.length} routes`);
|
|
58
|
+
ok(`${profile.components.length} components`);
|
|
59
|
+
ok(`${candidateCount} content candidates`);
|
|
60
|
+
ok(`${actionCount} interactive actions`);
|
|
61
|
+
if (graph.sharedFiles?.length) info(`${graph.sharedFiles.length} shared components reused across routes`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function printPlan(plan) {
|
|
65
|
+
heading('Planning editable contracts...');
|
|
66
|
+
ok(`${plan.stats.auto} high-confidence transformations`);
|
|
67
|
+
if (plan.stats.validate) info(`${plan.stats.validate} transformations queued with extra validation`);
|
|
68
|
+
const skippedDynamic = plan.skipped.filter((s) => /dynamic|api/.test(s.reason || '')).length;
|
|
69
|
+
const skippedLow = plan.skipped.filter((s) => (s.confidence || 1) < 0.6).length;
|
|
70
|
+
if (skippedDynamic) ok(`${skippedDynamic} existing dynamic values preserved`);
|
|
71
|
+
if (skippedLow) warn(`${skippedLow} low-confidence candidates skipped`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function printApply(result) {
|
|
75
|
+
heading('Applying Deneb contracts...');
|
|
76
|
+
ok(`${result.filesUpdated} files updated`);
|
|
77
|
+
if (result.layoutUpdated) ok('Root layout instrumented with SiteDataProvider');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function printValidation(validation, coverage, design) {
|
|
81
|
+
heading('Validating...');
|
|
82
|
+
if (validation.syntaxPassed) ok('AST');
|
|
83
|
+
else warn('AST validation reported parse issues');
|
|
84
|
+
if (validation.contractPassed) ok('editable contracts');
|
|
85
|
+
else warn('editable contract issues detected');
|
|
86
|
+
ok('manifest');
|
|
87
|
+
if (validation.idempotencyPassed !== false) ok('idempotency');
|
|
88
|
+
console.log('');
|
|
89
|
+
console.log(` Editable coverage: ${coverage.editableCoverage}%`);
|
|
90
|
+
console.log(` Design preservation: ${design.score}%`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function printExplain(plan) {
|
|
94
|
+
heading('Explain');
|
|
95
|
+
for (const file of plan.files) {
|
|
96
|
+
if (!file.transformations.length) continue;
|
|
97
|
+
console.log(` ${C.bold}${file.file}${C.reset}`);
|
|
98
|
+
for (const t of file.transformations) {
|
|
99
|
+
console.log(` - ${t.operation} ${t.field || t.urlField || ''}`);
|
|
100
|
+
console.log(` ${C.dim}why: ${t.reason} confidence: ${t.confidence} recipe: ${t.recipeId || 'none'}${C.reset}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function printDryRun(profile, plan) {
|
|
106
|
+
heading('Dry run (no files modified)');
|
|
107
|
+
console.log(` Technology: ${profile.framework} / ${profile.language} / ${(profile.cssSystems || []).join(', ') || 'css'}`);
|
|
108
|
+
console.log(` Routes: ${profile.routes.map((r) => r.route).join(', ')}`);
|
|
109
|
+
console.log(` Components scanned: ${profile.components.length}`);
|
|
110
|
+
console.log(` Planned transformations: ${plan.stats.planned}`);
|
|
111
|
+
console.log(` Files affected: ${plan.stats.filesAffected}`);
|
|
112
|
+
if (plan.skipped.length) {
|
|
113
|
+
console.log(` Risks:`);
|
|
114
|
+
for (const skip of plan.skipped.slice(0, 8)) {
|
|
115
|
+
console.log(` - ${skip.file}: ${skip.reason} (${skip.confidence})`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function printError(file, reason, confidence) {
|
|
121
|
+
console.log(`\n${C.red}Deneb could not safely determine the transformation in:${C.reset}\n`);
|
|
122
|
+
console.log(` ${file}\n`);
|
|
123
|
+
console.log(`Reason:\n ${reason}\n`);
|
|
124
|
+
if (confidence != null) console.log(`Confidence: ${confidence}\n`);
|
|
125
|
+
console.log('Action:\n Component left unchanged.\n');
|
|
126
|
+
console.log('No source code was damaged.\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function printSuccess() {
|
|
130
|
+
console.log(`\n${C.green}${C.bold}Deneb ARC completed successfully.${C.reset}\n`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function printRollback(reason) {
|
|
134
|
+
console.log(`\n${C.yellow}Deneb ARC rolled back the conversion.${C.reset}`);
|
|
135
|
+
console.log(`${C.dim}${reason}${C.reset}\n`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
printBanner,
|
|
140
|
+
printProfile,
|
|
141
|
+
printScan,
|
|
142
|
+
printPlan,
|
|
143
|
+
printApply,
|
|
144
|
+
printValidation,
|
|
145
|
+
printExplain,
|
|
146
|
+
printDryRun,
|
|
147
|
+
printError,
|
|
148
|
+
printSuccess,
|
|
149
|
+
printRollback,
|
|
150
|
+
ok,
|
|
151
|
+
warn,
|
|
152
|
+
info,
|
|
153
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { loadAllRecipes, matchRecipeForProject, getRecipeByName } = require('../tools/recipe-engine.cjs');
|
|
4
|
+
|
|
5
|
+
function matchRecipeV2(projectDir, profile, sourceFiles, recipeName) {
|
|
6
|
+
if (recipeName) {
|
|
7
|
+
const explicit = getRecipeByName(recipeName, projectDir);
|
|
8
|
+
if (explicit) return { recipe: normalizeRecipe(explicit), source: 'explicit' };
|
|
9
|
+
}
|
|
10
|
+
const matched = matchRecipeForProject(projectDir, profile.pkg || {}, sourceFiles);
|
|
11
|
+
if (matched) return { recipe: normalizeRecipe(matched), source: 'signature' };
|
|
12
|
+
return { recipe: null, source: null };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeRecipe(recipe) {
|
|
16
|
+
if (!recipe) return null;
|
|
17
|
+
return {
|
|
18
|
+
id: recipe.name || recipe.id,
|
|
19
|
+
name: recipe.name || recipe.id,
|
|
20
|
+
label: recipe.label || recipe.name,
|
|
21
|
+
version: recipe.version || '1.0.0',
|
|
22
|
+
fingerprint: recipe.signatures || {},
|
|
23
|
+
prerequisites: [],
|
|
24
|
+
detectors: recipe.signatures?.keywords || [],
|
|
25
|
+
transforms: [],
|
|
26
|
+
validators: [],
|
|
27
|
+
confidenceThreshold: 0.85,
|
|
28
|
+
successfulApplications: recipe.successfulApplications || 0,
|
|
29
|
+
failedApplications: recipe.failedApplications || 0,
|
|
30
|
+
actionRules: recipe.actionRules,
|
|
31
|
+
socialRules: recipe.socialRules,
|
|
32
|
+
gridRules: recipe.gridRules,
|
|
33
|
+
sections: recipe.sections,
|
|
34
|
+
pages: recipe.pages,
|
|
35
|
+
defaults: recipe.defaults,
|
|
36
|
+
signatures: recipe.signatures,
|
|
37
|
+
raw: recipe,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function listRecipes(projectDir) {
|
|
42
|
+
return loadAllRecipes(projectDir).map(normalizeRecipe);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
matchRecipeV2,
|
|
47
|
+
normalizeRecipe,
|
|
48
|
+
listRecipes,
|
|
49
|
+
};
|