@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,646 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const recast = require('recast');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
parseSource,
|
|
7
|
+
printSource,
|
|
8
|
+
locKey,
|
|
9
|
+
getJsxName,
|
|
10
|
+
findJsxAttribute,
|
|
11
|
+
hasJsxAttribute,
|
|
12
|
+
hasDirective,
|
|
13
|
+
ensureImport,
|
|
14
|
+
ensureDefaultImport,
|
|
15
|
+
siteDataBinding,
|
|
16
|
+
siteDataListBinding,
|
|
17
|
+
jsxPreviewAttr,
|
|
18
|
+
jsxTemplatePathAttr,
|
|
19
|
+
wrapTextInEditableSpan,
|
|
20
|
+
b,
|
|
21
|
+
} = require('./ast.cjs');
|
|
22
|
+
const { toPosix } = require('./fs-utils.cjs');
|
|
23
|
+
|
|
24
|
+
function findElementByLoc(ast, loc) {
|
|
25
|
+
let found = null;
|
|
26
|
+
recast.types.visit(ast, {
|
|
27
|
+
visitJSXElement(pathNode) {
|
|
28
|
+
if (locKey(pathNode.node) === loc) {
|
|
29
|
+
found = pathNode;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
this.traverse(pathNode);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function replaceAttrValue(node, attrName, expression) {
|
|
39
|
+
const attr = findJsxAttribute(node, attrName);
|
|
40
|
+
if (!attr) {
|
|
41
|
+
node.openingElement.attributes.push(
|
|
42
|
+
b.jsxAttribute(b.jsxIdentifier(attrName), b.jsxExpressionContainer(expression))
|
|
43
|
+
);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
attr.value = b.jsxExpressionContainer(expression);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensurePreviewPath(node, fieldPath) {
|
|
50
|
+
if (hasJsxAttribute(node, 'data-preview-field-path')) return;
|
|
51
|
+
node.openingElement.attributes.push(jsxPreviewAttr(fieldPath));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function replaceTextChildren(node, fieldPath, fallback, fieldType) {
|
|
55
|
+
const nextChildren = [];
|
|
56
|
+
let replaced = false;
|
|
57
|
+
for (const child of node.children || []) {
|
|
58
|
+
if (!child) continue;
|
|
59
|
+
if (child.type === 'JSXText' && child.value.replace(/\s+/g, '').length) {
|
|
60
|
+
nextChildren.push(b.jsxExpressionContainer(siteDataBinding(fieldPath.split('.'), fallback, fieldType)));
|
|
61
|
+
replaced = true;
|
|
62
|
+
} else if (
|
|
63
|
+
child.type === 'JSXExpressionContainer' &&
|
|
64
|
+
child.expression &&
|
|
65
|
+
(child.expression.type === 'StringLiteral' ||
|
|
66
|
+
child.expression.type === 'Literal' ||
|
|
67
|
+
child.expression.type === 'Identifier')
|
|
68
|
+
) {
|
|
69
|
+
nextChildren.push(b.jsxExpressionContainer(siteDataBinding(fieldPath.split('.'), fallback, fieldType)));
|
|
70
|
+
replaced = true;
|
|
71
|
+
} else {
|
|
72
|
+
nextChildren.push(child);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!replaced) {
|
|
76
|
+
nextChildren.push(b.jsxExpressionContainer(siteDataBinding(fieldPath.split('.'), fallback, fieldType)));
|
|
77
|
+
}
|
|
78
|
+
node.children = nextChildren;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function splitActionChildren(node, labelField, labelFallback) {
|
|
82
|
+
const nextChildren = [];
|
|
83
|
+
let wrapped = false;
|
|
84
|
+
for (const child of node.children || []) {
|
|
85
|
+
if (!child) continue;
|
|
86
|
+
if (child.type === 'JSXElement' && getJsxName(child) === 'span' && !hasJsxAttribute(child, 'data-preview-field-path')) {
|
|
87
|
+
ensurePreviewPath(child, labelField);
|
|
88
|
+
replaceTextChildren(child, labelField, labelFallback, 'text');
|
|
89
|
+
nextChildren.push(child);
|
|
90
|
+
wrapped = true;
|
|
91
|
+
} else if (child.type === 'JSXText' && child.value.replace(/\s+/g, '').length) {
|
|
92
|
+
const leading = child.value.match(/^\s*/)?.[0] || '';
|
|
93
|
+
const trailing = child.value.match(/\s*$/)?.[0] || '';
|
|
94
|
+
if (leading) nextChildren.push(b.jsxText(leading));
|
|
95
|
+
nextChildren.push(wrapTextInEditableSpan(labelField, labelFallback, 'text'));
|
|
96
|
+
if (trailing && trailing !== leading) nextChildren.push(b.jsxText(trailing));
|
|
97
|
+
wrapped = true;
|
|
98
|
+
} else if (
|
|
99
|
+
child.type === 'JSXExpressionContainer' &&
|
|
100
|
+
child.expression &&
|
|
101
|
+
(child.expression.type === 'StringLiteral' || child.expression.type === 'Literal')
|
|
102
|
+
) {
|
|
103
|
+
nextChildren.push(wrapTextInEditableSpan(labelField, labelFallback, 'text'));
|
|
104
|
+
wrapped = true;
|
|
105
|
+
} else {
|
|
106
|
+
nextChildren.push(child);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!wrapped) {
|
|
110
|
+
nextChildren.push(wrapTextInEditableSpan(labelField, labelFallback, 'text'));
|
|
111
|
+
}
|
|
112
|
+
node.children = nextChildren;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function applyTransformToElement(pathNode, transform) {
|
|
116
|
+
const node = pathNode.node;
|
|
117
|
+
if (transform.operation === 'split-action-contract') {
|
|
118
|
+
const urlParts = transform.urlField.split('.');
|
|
119
|
+
replaceAttrValue(node, 'href', siteDataBinding(urlParts, transform.fallback, 'url'));
|
|
120
|
+
ensurePreviewPath(node, transform.urlField);
|
|
121
|
+
splitActionChildren(node, transform.labelField, transform.labelFallback || '');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (transform.operation === 'extract-url') {
|
|
125
|
+
replaceAttrValue(node, 'href', siteDataBinding(transform.field.split('.'), transform.fallback, 'url'));
|
|
126
|
+
ensurePreviewPath(node, transform.field);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (transform.operation === 'extract-image') {
|
|
130
|
+
replaceAttrValue(node, 'src', siteDataBinding(transform.field.split('.'), transform.fallback, 'image'));
|
|
131
|
+
ensurePreviewPath(node, transform.field);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (transform.operation === 'extract-alt') {
|
|
135
|
+
replaceAttrValue(node, 'alt', siteDataBinding(transform.field.split('.'), transform.fallback, 'text'));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (transform.operation === 'extract-placeholder') {
|
|
139
|
+
replaceAttrValue(node, 'placeholder', siteDataBinding(transform.field.split('.'), transform.fallback, 'text'));
|
|
140
|
+
ensurePreviewPath(node, transform.field);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (transform.operation === 'wrap-text-span') {
|
|
144
|
+
wrapLiteralTextChildren(node, transform.field, transform.fallback);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (transform.operation === 'extract-text') {
|
|
148
|
+
ensurePreviewPath(node, transform.field);
|
|
149
|
+
replaceTextChildren(node, transform.field, transform.fallback, transform.fieldType || 'text');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Replaces literal text children with an editable <span>, leaving the container
|
|
155
|
+
* element and every one of its classes untouched.
|
|
156
|
+
*/
|
|
157
|
+
function wrapLiteralTextChildren(node, fieldPath, fallback) {
|
|
158
|
+
const nextChildren = [];
|
|
159
|
+
let wrapped = false;
|
|
160
|
+
|
|
161
|
+
for (const child of node.children || []) {
|
|
162
|
+
if (!child) continue;
|
|
163
|
+
if (!wrapped && child.type === 'JSXText' && child.value.replace(/\s+/g, '').length) {
|
|
164
|
+
const leading = child.value.match(/^\s*/)?.[0] || '';
|
|
165
|
+
const trailing = child.value.match(/\s*$/)?.[0] || '';
|
|
166
|
+
if (leading) nextChildren.push(b.jsxText(leading));
|
|
167
|
+
nextChildren.push(wrapTextInEditableSpan(fieldPath, fallback, 'text'));
|
|
168
|
+
if (trailing) nextChildren.push(b.jsxText(trailing));
|
|
169
|
+
wrapped = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (child.type === 'JSXText' && child.value.replace(/\s+/g, '').length) continue;
|
|
173
|
+
nextChildren.push(child);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (wrapped) node.children = nextChildren;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Converts a literal-array `.map()` render into a Fivora list contract.
|
|
181
|
+
*
|
|
182
|
+
* The developer's array declaration becomes site-data backed with the original
|
|
183
|
+
* literal as fallback, so `{item.title}` keeps working untouched. Markers are
|
|
184
|
+
* emitted as JSX template literals (`items[${index}].title`) so added and
|
|
185
|
+
* reordered items stay editable, which is what strict mode requires.
|
|
186
|
+
*/
|
|
187
|
+
function applyCollectionTransform(ast, transform) {
|
|
188
|
+
const listPath = transform.listField;
|
|
189
|
+
const binding = transform.itemParam;
|
|
190
|
+
if (!listPath || !binding) return false;
|
|
191
|
+
|
|
192
|
+
const mapCall = findMapCall(ast, transform.loc);
|
|
193
|
+
if (!mapCall) return false;
|
|
194
|
+
|
|
195
|
+
const callback = (mapCall.node.arguments || [])[0];
|
|
196
|
+
if (!callback) return false;
|
|
197
|
+
|
|
198
|
+
// The contract needs a concrete index for each item marker.
|
|
199
|
+
let indexName = transform.indexParam;
|
|
200
|
+
if (!indexName) {
|
|
201
|
+
indexName = callback.params.some((p) => p.name === 'index') ? 'denebIndex' : 'index';
|
|
202
|
+
callback.params.push(b.identifier(indexName));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const itemRoot = findElementByLoc(ast, transform.loc);
|
|
206
|
+
if (!itemRoot) return false;
|
|
207
|
+
|
|
208
|
+
if (!hasJsxAttribute(itemRoot.node, 'data-preview-item-path')) {
|
|
209
|
+
itemRoot.node.openingElement.attributes.push(
|
|
210
|
+
jsxTemplatePathAttr('data-preview-item-path', listPath, indexName)
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
markItemFields(callback, { listPath, binding, indexName });
|
|
215
|
+
|
|
216
|
+
const container = findListContainer(mapCall);
|
|
217
|
+
if (container && !hasJsxAttribute(container, 'data-preview-list-path')) {
|
|
218
|
+
container.openingElement.attributes.push(
|
|
219
|
+
b.jsxAttribute(b.jsxIdentifier('data-preview-list-path'), b.stringLiteral(listPath))
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return bindArrayDeclaration(ast, mapCall, listPath);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function findMapCall(ast, loc) {
|
|
227
|
+
let found = null;
|
|
228
|
+
recast.types.visit(ast, {
|
|
229
|
+
visitCallExpression(pathNode) {
|
|
230
|
+
const callee = pathNode.node.callee;
|
|
231
|
+
if (
|
|
232
|
+
!found &&
|
|
233
|
+
callee?.type === 'MemberExpression' &&
|
|
234
|
+
!callee.computed &&
|
|
235
|
+
callee.property?.name === 'map'
|
|
236
|
+
) {
|
|
237
|
+
let hit = false;
|
|
238
|
+
recast.types.visit(pathNode.node, {
|
|
239
|
+
visitJSXElement(inner) {
|
|
240
|
+
if (locKey(inner.node) === loc) {
|
|
241
|
+
hit = true;
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
inner.traverse(inner);
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
if (hit) {
|
|
248
|
+
found = pathNode;
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
this.traverse(pathNode);
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
return found;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Attaches field markers to the elements that render item properties. */
|
|
259
|
+
function markItemFields(callback, { listPath, binding, indexName }) {
|
|
260
|
+
recast.types.visit(callback, {
|
|
261
|
+
visitJSXElement(pathNode) {
|
|
262
|
+
const node = pathNode.node;
|
|
263
|
+
|
|
264
|
+
for (const attr of node.openingElement.attributes || []) {
|
|
265
|
+
if (attr.type !== 'JSXAttribute' || !attr.value) continue;
|
|
266
|
+
const attrName = attr.name?.name;
|
|
267
|
+
if (attrName !== 'src' && attrName !== 'href') continue;
|
|
268
|
+
const property = itemMemberName(attr.value.expression, binding);
|
|
269
|
+
if (!property) continue;
|
|
270
|
+
if (!hasJsxAttribute(node, 'data-preview-field-path')) {
|
|
271
|
+
node.openingElement.attributes.push(
|
|
272
|
+
jsxTemplatePathAttr('data-preview-field-path', listPath, indexName, `.${property}`)
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const textChild = (node.children || []).find(
|
|
278
|
+
(child) => child.type === 'JSXExpressionContainer' && itemMemberName(child.expression, binding)
|
|
279
|
+
);
|
|
280
|
+
if (textChild && !hasJsxAttribute(node, 'data-preview-field-path')) {
|
|
281
|
+
const property = itemMemberName(textChild.expression, binding);
|
|
282
|
+
node.openingElement.attributes.push(
|
|
283
|
+
jsxTemplatePathAttr('data-preview-field-path', listPath, indexName, `.${property}`)
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
this.traverse(pathNode);
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function itemMemberName(expr, binding) {
|
|
293
|
+
if (!expr || expr.type !== 'MemberExpression' || expr.computed) return null;
|
|
294
|
+
if (expr.object?.type !== 'Identifier' || expr.object.name !== binding) return null;
|
|
295
|
+
return expr.property?.name || null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The nearest JSX element that wraps the map expression. */
|
|
299
|
+
function findListContainer(mapCallPath) {
|
|
300
|
+
let current = mapCallPath.parent;
|
|
301
|
+
while (current) {
|
|
302
|
+
const node = current.node || current.value;
|
|
303
|
+
if (node?.type === 'JSXElement') return node;
|
|
304
|
+
current = current.parentPath || current.parent;
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function bindArrayDeclaration(ast, mapCallPath, listPath) {
|
|
310
|
+
const arrayName = mapCallPath.node.callee.object?.name;
|
|
311
|
+
if (!arrayName) return false;
|
|
312
|
+
let bound = false;
|
|
313
|
+
|
|
314
|
+
recast.types.visit(ast, {
|
|
315
|
+
visitVariableDeclarator(pathNode) {
|
|
316
|
+
const node = pathNode.node;
|
|
317
|
+
if (bound || node.id?.type !== 'Identifier' || node.id.name !== arrayName) {
|
|
318
|
+
this.traverse(pathNode);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (node.init?.type !== 'ArrayExpression') {
|
|
322
|
+
this.traverse(pathNode);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
node.init = siteDataListBinding(listPath.split('.'), node.init);
|
|
326
|
+
bound = true;
|
|
327
|
+
return false;
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
return bound;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function injectSiteDataHook(ast) {
|
|
335
|
+
const program = ast.program || ast;
|
|
336
|
+
let injected = false;
|
|
337
|
+
|
|
338
|
+
function injectIntoFunction(fn) {
|
|
339
|
+
if (!fn || !fn.body || fn.body.type !== 'BlockStatement') return false;
|
|
340
|
+
const body = fn.body.body;
|
|
341
|
+
const already = body.some((stmt) => recast.print(stmt).code.includes('useSiteData'));
|
|
342
|
+
if (already) return true;
|
|
343
|
+
const hook = b.variableDeclaration('const', [
|
|
344
|
+
b.variableDeclarator(
|
|
345
|
+
b.identifier('siteData'),
|
|
346
|
+
b.callExpression(b.identifier('useSiteData'), [])
|
|
347
|
+
),
|
|
348
|
+
]);
|
|
349
|
+
body.unshift(hook);
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
recast.types.visit(ast, {
|
|
354
|
+
visitExportDefaultDeclaration(pathNode) {
|
|
355
|
+
if (injected) return false;
|
|
356
|
+
const decl = pathNode.node.declaration;
|
|
357
|
+
if (decl && (decl.type === 'FunctionDeclaration' || decl.type === 'ArrowFunctionExpression' || decl.type === 'FunctionExpression')) {
|
|
358
|
+
injected = injectIntoFunction(decl);
|
|
359
|
+
}
|
|
360
|
+
this.traverse(pathNode);
|
|
361
|
+
},
|
|
362
|
+
visitFunctionDeclaration(pathNode) {
|
|
363
|
+
if (injected) return false;
|
|
364
|
+
const name = pathNode.node.id && pathNode.node.id.name;
|
|
365
|
+
if (name && /^[A-Z]/.test(name)) {
|
|
366
|
+
injected = injectIntoFunction(pathNode.node);
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
this.traverse(pathNode);
|
|
370
|
+
},
|
|
371
|
+
visitVariableDeclarator(pathNode) {
|
|
372
|
+
if (injected) return false;
|
|
373
|
+
const id = pathNode.node.id;
|
|
374
|
+
const init = pathNode.node.init;
|
|
375
|
+
if (id && id.type === 'Identifier' && /^[A-Z]/.test(id.name) && init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) {
|
|
376
|
+
injected = injectIntoFunction(init);
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
this.traverse(pathNode);
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
if (!injected) {
|
|
384
|
+
recast.types.visit(ast, {
|
|
385
|
+
visitFunctionDeclaration(pathNode) {
|
|
386
|
+
if (injected) return false;
|
|
387
|
+
injected = injectIntoFunction(pathNode.node);
|
|
388
|
+
return false;
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return injected;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function resolveSiteDataSpecifier(profile, fromRelativeFile) {
|
|
397
|
+
const aliases = profile.aliasMap || {};
|
|
398
|
+
const hasAt = Object.keys(aliases).some((k) => k === '@/*' || k.startsWith('@/'));
|
|
399
|
+
const siteDataRel = profile.hasSrc ? 'src/data/site-data.json' : 'data/site-data.json';
|
|
400
|
+
if (hasAt && profile.hasSrc) return '@/data/site-data.json';
|
|
401
|
+
|
|
402
|
+
const fromAbs = path.join(profile.root, fromRelativeFile);
|
|
403
|
+
const toAbs = path.join(profile.root, siteDataRel);
|
|
404
|
+
let relSpec = path.relative(path.dirname(fromAbs), toAbs).replace(/\\/g, '/');
|
|
405
|
+
if (!relSpec.startsWith('.')) relSpec = './' + relSpec;
|
|
406
|
+
return relSpec;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function applyFilePlan(filePlan, profile) {
|
|
410
|
+
if (!filePlan.originalCode || !filePlan.transformations.length) {
|
|
411
|
+
return { code: filePlan.originalCode, changed: false };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const ast = parseSource(filePlan.originalCode, filePlan.file);
|
|
415
|
+
const isClient = hasDirective(ast, 'use client') || /['"]use client['"]/.test(filePlan.originalCode.slice(0, 400));
|
|
416
|
+
let applied = 0;
|
|
417
|
+
const failures = [];
|
|
418
|
+
|
|
419
|
+
const supported = new Set([
|
|
420
|
+
'split-action-contract',
|
|
421
|
+
'extract-url',
|
|
422
|
+
'extract-image',
|
|
423
|
+
'extract-alt',
|
|
424
|
+
'extract-placeholder',
|
|
425
|
+
'extract-text',
|
|
426
|
+
'wrap-text-span',
|
|
427
|
+
'collection-conversion',
|
|
428
|
+
]);
|
|
429
|
+
|
|
430
|
+
// Collections run first: they rewrite the array declaration and add an index
|
|
431
|
+
// parameter, and later per-element edits must observe that shape.
|
|
432
|
+
const ordered = [...filePlan.transformations].sort(
|
|
433
|
+
(left, right) =>
|
|
434
|
+
Number(right.operation === 'collection-conversion') -
|
|
435
|
+
Number(left.operation === 'collection-conversion')
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
for (const transform of ordered) {
|
|
439
|
+
if (transform.decision === 'skip') continue;
|
|
440
|
+
if (!supported.has(transform.operation)) continue;
|
|
441
|
+
|
|
442
|
+
if (transform.operation === 'collection-conversion') {
|
|
443
|
+
try {
|
|
444
|
+
if (applyCollectionTransform(ast, transform)) applied++;
|
|
445
|
+
else failures.push({ loc: transform.loc, reason: 'collection-not-bindable' });
|
|
446
|
+
} catch (err) {
|
|
447
|
+
failures.push({ loc: transform.loc, reason: err.message });
|
|
448
|
+
}
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const pathNode = findElementByLoc(ast, transform.loc);
|
|
453
|
+
if (!pathNode) {
|
|
454
|
+
failures.push({ loc: transform.loc, reason: 'node-not-found' });
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
applyTransformToElement(pathNode, transform);
|
|
459
|
+
applied++;
|
|
460
|
+
} catch (err) {
|
|
461
|
+
failures.push({ loc: transform.loc, reason: err.message });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (applied === 0) {
|
|
466
|
+
return { code: filePlan.originalCode, changed: false, applied, failures };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const siteDataImport = resolveSiteDataSpecifier(profile, filePlan.file);
|
|
470
|
+
if (isClient) {
|
|
471
|
+
ensureImport(ast, '@deneb-ui/ui', ['useSiteData']);
|
|
472
|
+
injectSiteDataHook(ast);
|
|
473
|
+
} else {
|
|
474
|
+
ensureDefaultImport(ast, siteDataImport, 'siteData');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Page keys are stamped in a separate route-driven pass so App Router and
|
|
478
|
+
// Pages Router projects are handled by the same logic.
|
|
479
|
+
const code = printSource(ast, filePlan.originalCode);
|
|
480
|
+
return { code, changed: true, applied, failures, usedClientHook: isClient };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Stamps a route's page component with data-preview-page-key. Fivora requires
|
|
485
|
+
* exactly one per exported route, so the key comes from the scanned route id
|
|
486
|
+
* rather than being guessed from the filename — Pages Router uses
|
|
487
|
+
* `pages/contact.jsx`, App Router uses `app/contact/page.tsx`.
|
|
488
|
+
*
|
|
489
|
+
* `<main>` is preferred, but any project shape is supported by falling back to
|
|
490
|
+
* the outermost element the page component returns.
|
|
491
|
+
*/
|
|
492
|
+
function instrumentPageKey(code, relativeFile, pageKey) {
|
|
493
|
+
if (code.includes('data-preview-page-key')) return { code, updated: false };
|
|
494
|
+
const key = pageKey || inferPageKey(relativeFile);
|
|
495
|
+
|
|
496
|
+
let ast;
|
|
497
|
+
try {
|
|
498
|
+
ast = parseSource(code, relativeFile);
|
|
499
|
+
} catch {
|
|
500
|
+
return { code, updated: false };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const attribute = () =>
|
|
504
|
+
b.jsxAttribute(b.jsxIdentifier('data-preview-page-key'), b.stringLiteral(key));
|
|
505
|
+
|
|
506
|
+
let target = null;
|
|
507
|
+
recast.types.visit(ast, {
|
|
508
|
+
visitJSXOpeningElement(pathNode) {
|
|
509
|
+
const name = pathNode.node.name && pathNode.node.name.name;
|
|
510
|
+
if (!target && name === 'main') {
|
|
511
|
+
target = pathNode.node;
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
this.traverse(pathNode);
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
if (!target) {
|
|
519
|
+
// No <main>: use the first host element of the returned tree so the marker
|
|
520
|
+
// still lands inside this route's exported HTML.
|
|
521
|
+
recast.types.visit(ast, {
|
|
522
|
+
visitJSXElement(pathNode) {
|
|
523
|
+
if (target) return false;
|
|
524
|
+
const name = getJsxName(pathNode.node);
|
|
525
|
+
if (typeof name === 'string' && /^[a-z]/.test(name)) {
|
|
526
|
+
target = pathNode.node.openingElement;
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
this.traverse(pathNode);
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (!target) return { code, updated: false };
|
|
535
|
+
target.attributes = target.attributes || [];
|
|
536
|
+
target.attributes.unshift(attribute());
|
|
537
|
+
return { code: printSource(ast, code), updated: true };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function inferPageKey(relativeFile) {
|
|
541
|
+
const posix = toPosix(relativeFile);
|
|
542
|
+
if (/(^|\/)page\.(tsx|jsx|js)$/.test(posix)) {
|
|
543
|
+
const parts = posix.split('/');
|
|
544
|
+
const idx = parts.lastIndexOf('app');
|
|
545
|
+
const segs = parts.slice(idx + 1, -1).filter((s) => !(s.startsWith('(') && s.endsWith(')')));
|
|
546
|
+
if (!segs.length) return 'home';
|
|
547
|
+
return segs.join('_').replace(/[^a-z0-9_-]/gi, '_').toLowerCase();
|
|
548
|
+
}
|
|
549
|
+
return 'home';
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function instrumentLayoutSource(code, siteDataImport) {
|
|
553
|
+
if (/SiteDataProvider|DenebDataProvider/.test(code)) {
|
|
554
|
+
return { code, updated: false };
|
|
555
|
+
}
|
|
556
|
+
const ast = parseSource(code, 'layout.tsx');
|
|
557
|
+
ensureImport(ast, '@deneb-ui/ui', ['SiteDataProvider']);
|
|
558
|
+
ensureDefaultImport(ast, siteDataImport, 'initialSiteData');
|
|
559
|
+
|
|
560
|
+
let wrapped = false;
|
|
561
|
+
recast.types.visit(ast, {
|
|
562
|
+
visitJSXExpressionContainer(pathNode) {
|
|
563
|
+
if (wrapped) return false;
|
|
564
|
+
const expr = pathNode.node.expression;
|
|
565
|
+
if (expr && expr.type === 'Identifier' && expr.name === 'children') {
|
|
566
|
+
pathNode.replace(
|
|
567
|
+
b.jsxElement(
|
|
568
|
+
b.jsxOpeningElement(
|
|
569
|
+
b.jsxIdentifier('SiteDataProvider'),
|
|
570
|
+
[
|
|
571
|
+
b.jsxAttribute(
|
|
572
|
+
b.jsxIdentifier('initialSiteData'),
|
|
573
|
+
b.jsxExpressionContainer(b.identifier('initialSiteData'))
|
|
574
|
+
),
|
|
575
|
+
],
|
|
576
|
+
false
|
|
577
|
+
),
|
|
578
|
+
b.jsxClosingElement(b.jsxIdentifier('SiteDataProvider')),
|
|
579
|
+
[pathNode.node],
|
|
580
|
+
false
|
|
581
|
+
)
|
|
582
|
+
);
|
|
583
|
+
wrapped = true;
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
this.traverse(pathNode);
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
if (!wrapped) {
|
|
591
|
+
recast.types.visit(ast, {
|
|
592
|
+
visitJSXElement(pathNode) {
|
|
593
|
+
if (wrapped) return false;
|
|
594
|
+
const name = getJsxName(pathNode.node);
|
|
595
|
+
if (name === 'Component') {
|
|
596
|
+
pathNode.replace(
|
|
597
|
+
b.jsxElement(
|
|
598
|
+
b.jsxOpeningElement(
|
|
599
|
+
b.jsxIdentifier('SiteDataProvider'),
|
|
600
|
+
[
|
|
601
|
+
b.jsxAttribute(
|
|
602
|
+
b.jsxIdentifier('initialSiteData'),
|
|
603
|
+
b.jsxExpressionContainer(b.identifier('initialSiteData'))
|
|
604
|
+
),
|
|
605
|
+
],
|
|
606
|
+
false
|
|
607
|
+
),
|
|
608
|
+
b.jsxClosingElement(b.jsxIdentifier('SiteDataProvider')),
|
|
609
|
+
[pathNode.node],
|
|
610
|
+
false
|
|
611
|
+
)
|
|
612
|
+
);
|
|
613
|
+
wrapped = true;
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
this.traverse(pathNode);
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return { code: printSource(ast, code), updated: wrapped };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function ensureJsonModule(tsconfig) {
|
|
625
|
+
if (!tsconfig || !tsconfig.compilerOptions) return { config: tsconfig, changed: false };
|
|
626
|
+
if (tsconfig.compilerOptions.resolveJsonModule) return { config: tsconfig, changed: false };
|
|
627
|
+
return {
|
|
628
|
+
config: {
|
|
629
|
+
...tsconfig,
|
|
630
|
+
compilerOptions: {
|
|
631
|
+
...tsconfig.compilerOptions,
|
|
632
|
+
resolveJsonModule: true,
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
changed: true,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
module.exports = {
|
|
640
|
+
applyFilePlan,
|
|
641
|
+
instrumentLayoutSource,
|
|
642
|
+
instrumentPageKey,
|
|
643
|
+
resolveSiteDataSpecifier,
|
|
644
|
+
ensureJsonModule,
|
|
645
|
+
inferPageKey,
|
|
646
|
+
};
|