@deneb-ui/cli 2.0.88 → 2.0.90

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.
Files changed (55) hide show
  1. package/bin/index.js +31 -5
  2. package/package.json +2 -2
  3. package/src/arc/__tests__/acceptance-gates.test.cjs +62 -0
  4. package/src/arc/__tests__/arc.test.cjs +922 -0
  5. package/src/arc/__tests__/canonical.test.cjs +57 -0
  6. package/src/arc/__tests__/classification.test.cjs +54 -0
  7. package/src/arc/__tests__/corpus.test.cjs +144 -0
  8. package/src/arc/__tests__/explain-blocked.test.cjs +192 -0
  9. package/src/arc/__tests__/fuzz.test.cjs +106 -0
  10. package/src/arc/__tests__/interaction.test.cjs +85 -0
  11. package/src/arc/__tests__/visual-regression.test.cjs +99 -0
  12. package/src/arc/acceptance-gates.cjs +119 -0
  13. package/src/arc/canonical-paths.cjs +142 -23
  14. package/src/arc/corpus-verifier.cjs +295 -0
  15. package/src/arc/data-classification.cjs +182 -0
  16. package/src/arc/data-flow.cjs +62 -2
  17. package/src/arc/explain-blocked.cjs +388 -0
  18. package/src/arc/fuzz-engine.cjs +604 -0
  19. package/src/arc/index.cjs +148 -2
  20. package/src/arc/interaction-verifier.cjs +185 -0
  21. package/src/arc/ir-builder.cjs +170 -12
  22. package/src/arc/planner.cjs +16 -1
  23. package/src/arc/residual.cjs +22 -0
  24. package/src/arc/rsc-boundary.cjs +105 -4
  25. package/src/arc/runtime-validator.cjs +65 -0
  26. package/src/arc/semantic.cjs +12 -1
  27. package/src/arc/transformer.cjs +14 -2745
  28. package/src/arc/transforms/assets/tailwind-bg.cjs +38 -0
  29. package/src/arc/transforms/collections/list.cjs +474 -0
  30. package/src/arc/transforms/components/child-card.cjs +268 -0
  31. package/src/arc/transforms/components/compound-content.cjs +49 -0
  32. package/src/arc/transforms/components/reusable-component.cjs +173 -0
  33. package/src/arc/transforms/element-transform.cjs +192 -0
  34. package/src/arc/transforms/healing/conditionals.cjs +295 -0
  35. package/src/arc/transforms/healing/sanitizer.cjs +401 -0
  36. package/src/arc/transforms/index.cjs +195 -0
  37. package/src/arc/transforms/primitives/action.cjs +76 -0
  38. package/src/arc/transforms/primitives/text.cjs +65 -0
  39. package/src/arc/transforms/routing/page-key.cjs +84 -0
  40. package/src/arc/transforms/routing/shared-layout.cjs +294 -0
  41. package/src/arc/transforms/runtime/provider.cjs +230 -0
  42. package/src/arc/transforms/transform-context.cjs +118 -0
  43. package/src/arc/types/acceptance.d.ts +50 -0
  44. package/src/arc/types/canonical.d.ts +50 -0
  45. package/src/arc/types/classification.d.ts +42 -0
  46. package/src/arc/types/corpus.d.ts +52 -0
  47. package/src/arc/types/explain-blocked.d.ts +41 -0
  48. package/src/arc/types/fuzz.d.ts +74 -0
  49. package/src/arc/types/interaction.d.ts +43 -0
  50. package/src/arc/types/ir.d.ts +114 -0
  51. package/src/arc/types/rsc.d.ts +39 -0
  52. package/src/arc/types/visual-regression.d.ts +49 -0
  53. package/src/arc/visual-regression.cjs +195 -0
  54. package/src/arc/workspace.cjs +233 -0
  55. package/src/common/template-visual-edit-contract.ts +4 -1
@@ -1,2751 +1,20 @@
1
1
  'use strict';
2
2
 
3
- const recast = require('recast');
4
- const path = require('path');
5
- const fs = require('fs');
6
- const {
7
- parseSource,
8
- printSource,
9
- locKey,
10
- getJsxName,
11
- findJsxAttribute,
12
- hasJsxAttribute,
13
- hasDirective,
14
- ensureImport,
15
- ensureDefaultImport,
16
- sanitizeDuplicateBindings,
17
- siteDataBinding,
18
- siteDataListBinding,
19
- jsxPreviewAttr,
20
- jsxTemplatePathAttr,
21
- wrapTextInEditableSpan,
22
- unwrapExpr,
23
- extractItemMemberName,
24
- jsxStyleAttrs,
25
- ensureStyleAttrs,
26
- b,
27
- } = require('./ast.cjs');
28
- const { toPosix } = require('./fs-utils.cjs');
29
- const { BROAD_CONTENT_CONTAINERS } = require('./fivora-contract.cjs');
30
- const {
31
- hasMetadataExport,
32
- hasClientDirective,
33
- needsClientDirective,
34
- canSafelyInjectClientDirective,
35
- ensureClientDirective,
36
- } = require('./rsc-boundary.cjs');
37
-
38
- function findElementByLoc(ast, loc, tagName = null) {
39
- if (!loc && !tagName) return null;
40
- const parts = loc ? loc.split(':') : [];
41
- const targetLoc = parts.length > 4 ? parts.slice(0, 4).join(':') : loc;
42
- let found = null;
43
- recast.types.visit(ast, {
44
- visitJSXElement(pathNode) {
45
- const k = locKey(pathNode.node);
46
- const name = getJsxName(pathNode.node);
47
- if (loc && (k === targetLoc || k === loc || (parts.length >= 2 && k && k.startsWith(`${parts[0]}:${parts[1]}`)))) {
48
- found = pathNode;
49
- return false;
50
- }
51
- if (!loc && tagName && (name === tagName || name.endsWith(`.${tagName}`))) {
52
- found = pathNode;
53
- return false;
54
- }
55
- this.traverse(pathNode);
56
- },
57
- });
58
- return found;
59
- }
60
-
61
- function replaceAttrValue(node, attrName, expression) {
62
- const attr = findJsxAttribute(node, attrName);
63
- if (!attr) {
64
- node.openingElement.attributes.push(
65
- b.jsxAttribute(b.jsxIdentifier(attrName), b.jsxExpressionContainer(expression))
66
- );
67
- return;
68
- }
69
- attr.value = b.jsxExpressionContainer(expression);
70
- }
71
-
72
- function stripStaticAttribute(node) {
73
- if (node && node.openingElement && Array.isArray(node.openingElement.attributes)) {
74
- node.openingElement.attributes = node.openingElement.attributes.filter(
75
- (attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'data-preview-static')
76
- );
77
- }
78
- }
79
-
80
- function ensurePreviewPath(node, fieldPath) {
81
- stripStaticAttribute(node);
82
- if (hasJsxAttribute(node, 'data-preview-field-path')) return;
83
- node.openingElement.attributes.push(jsxPreviewAttr(fieldPath));
84
- }
85
-
86
- function replaceTextChildren(node, fieldPath, fallback, fieldType) {
87
- const nextChildren = [];
88
- let replaced = false;
89
- for (const child of node.children || []) {
90
- if (!child) continue;
91
- if (child.type === 'JSXText' && child.value.replace(/\s+/g, '').length) {
92
- nextChildren.push(b.jsxExpressionContainer(siteDataBinding(fieldPath.split('.'), fallback, fieldType)));
93
- replaced = true;
94
- } else if (
95
- child.type === 'JSXExpressionContainer' &&
96
- child.expression &&
97
- (child.expression.type === 'StringLiteral' ||
98
- child.expression.type === 'Literal' ||
99
- child.expression.type === 'Identifier')
100
- ) {
101
- nextChildren.push(b.jsxExpressionContainer(siteDataBinding(fieldPath.split('.'), fallback, fieldType)));
102
- replaced = true;
103
- } else {
104
- nextChildren.push(child);
105
- }
106
- }
107
- if (!replaced) {
108
- nextChildren.push(b.jsxExpressionContainer(siteDataBinding(fieldPath.split('.'), fallback, fieldType)));
109
- }
110
- node.children = nextChildren;
111
- }
112
-
113
- function splitActionChildren(node, labelField, labelFallback) {
114
- const nextChildren = [];
115
- let wrapped = false;
116
- for (const child of node.children || []) {
117
- if (!child) continue;
118
- if (child.type === 'JSXElement' && getJsxName(child) === 'span' && !hasJsxAttribute(child, 'data-preview-field-path')) {
119
- ensurePreviewPath(child, labelField);
120
- ensureStyleAttrs(child, labelField, 'text');
121
- replaceTextChildren(child, labelField, labelFallback, 'text');
122
- nextChildren.push(child);
123
- wrapped = true;
124
- } else if (child.type === 'JSXText' && child.value.replace(/\s+/g, '').length) {
125
- const leading = child.value.match(/^\s*/)?.[0] || '';
126
- const trailing = child.value.match(/\s*$/)?.[0] || '';
127
- if (leading) nextChildren.push(b.jsxText(leading));
128
- nextChildren.push(wrapTextInEditableSpan(labelField, labelFallback, 'text'));
129
- if (trailing && trailing !== leading) nextChildren.push(b.jsxText(trailing));
130
- wrapped = true;
131
- } else if (
132
- child.type === 'JSXExpressionContainer' &&
133
- child.expression &&
134
- (child.expression.type === 'StringLiteral' || child.expression.type === 'Literal')
135
- ) {
136
- nextChildren.push(wrapTextInEditableSpan(labelField, labelFallback, 'text'));
137
- wrapped = true;
138
- } else {
139
- nextChildren.push(child);
140
- }
141
- }
142
- if (!wrapped) {
143
- nextChildren.push(wrapTextInEditableSpan(labelField, labelFallback, 'text'));
144
- }
145
- node.children = nextChildren;
146
- }
147
-
148
- function wrapHiddenUrlSibling(pathNode, urlField, fallback) {
149
- const urlParts = urlField.split('.');
150
- const hiddenUrl = b.jsxElement(
151
- b.jsxOpeningElement(
152
- b.jsxIdentifier('span'),
153
- [
154
- b.jsxAttribute(b.jsxIdentifier('hidden')),
155
- b.jsxAttribute(b.jsxIdentifier('aria-hidden'), b.stringLiteral('true')),
156
- jsxPreviewAttr(urlField),
157
- ],
158
- false
159
- ),
160
- b.jsxClosingElement(b.jsxIdentifier('span')),
161
- [b.jsxExpressionContainer(siteDataBinding(urlParts, fallback, 'url'))],
162
- false
163
- );
164
- if (pathNode.parent && Array.isArray(pathNode.parent.node?.children)) {
165
- pathNode.insertAfter(hiddenUrl);
166
- } else if (pathNode.node && Array.isArray(pathNode.node.children)) {
167
- pathNode.node.children = [hiddenUrl, ...(pathNode.node.children || [])];
168
- }
169
- }
170
-
171
- function applyTransformToElement(pathNode, transform) {
172
- const node = pathNode.node;
173
- if (transform.operation === 'form-submit-action') {
174
- const tagName = getJsxName(node);
175
- if (!hasJsxAttribute(node, 'type') && tagName === 'button') {
176
- node.openingElement.attributes.push(
177
- b.jsxAttribute(b.jsxIdentifier('type'), b.stringLiteral('submit'))
178
- );
179
- }
180
- if (transform.labelField) {
181
- if (hasJsxAttribute(node, 'data-preview-static')) {
182
- node.openingElement.attributes = node.openingElement.attributes.filter(
183
- (attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'data-preview-static')
184
- );
185
- }
186
- if (hasJsxAttribute(node, 'data-preview-field-path')) {
187
- node.openingElement.attributes = node.openingElement.attributes.filter(
188
- (attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'data-preview-field-path')
189
- );
190
- }
191
- splitActionChildren(node, transform.labelField, transform.labelFallback || '');
192
- } else if (!hasJsxAttribute(node, 'data-preview-static')) {
193
- node.openingElement.attributes.push(
194
- b.jsxAttribute(b.jsxIdentifier('data-preview-static'), b.stringLiteral('action-button'))
195
- );
196
- }
197
- return;
198
- }
199
- if (transform.operation === 'split-action-contract') {
200
- const tagName = getJsxName(node);
201
- if (tagName === 'button') {
202
- node.openingElement.name = b.jsxIdentifier('a');
203
- if (node.closingElement) {
204
- node.closingElement.name = b.jsxIdentifier('a');
205
- }
206
- node.openingElement.attributes = (node.openingElement.attributes || []).filter(
207
- (attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'type')
208
- );
209
- }
210
-
211
- const urlParts = transform.urlField.split('.');
212
- replaceAttrValue(node, 'href', siteDataBinding(urlParts, transform.fallback, 'url'));
213
-
214
- const isExternal =
215
- transform.extra?.external ||
216
- ['whatsapp', 'directions', 'location'].includes(transform.extra?.action) ||
217
- /^https?:\/\//i.test(String(transform.fallback || ''));
218
-
219
- if (isExternal && (tagName === 'button' || tagName === 'a')) {
220
- if (!hasJsxAttribute(node, 'target')) {
221
- node.openingElement.attributes.push(
222
- b.jsxAttribute(b.jsxIdentifier('target'), b.stringLiteral('_blank'))
223
- );
224
- }
225
- if (!hasJsxAttribute(node, 'rel')) {
226
- node.openingElement.attributes.push(
227
- b.jsxAttribute(b.jsxIdentifier('rel'), b.stringLiteral('noopener noreferrer'))
228
- );
229
- }
230
- }
231
-
232
- // Fivora Strict Action/Label Contract:
233
- // Bind the URL action marker directly to the outer <a> element (never hidden)
234
- replaceAttrValue(node, 'data-preview-field-path', b.stringLiteral(transform.urlField));
235
- // Remove data-preview-static if present to prevent static/field collisions
236
- if (hasJsxAttribute(node, 'data-preview-static')) {
237
- node.openingElement.attributes = node.openingElement.attributes.filter(
238
- (attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'data-preview-static')
239
- );
240
- }
241
- // Bind the label to the inner text child
242
- splitActionChildren(node, transform.labelField, transform.labelFallback || '');
243
- return;
244
- }
245
- if (transform.operation === 'extract-url') {
246
- replaceAttrValue(node, 'href', siteDataBinding(transform.field.split('.'), transform.fallback, 'url'));
247
- ensurePreviewPath(node, transform.field);
248
- return;
249
- }
250
- if (transform.operation === 'extract-image' || transform.operation === 'extract-picture') {
251
- const tagName = getJsxName(node);
252
- if (tagName === 'picture') {
253
- const children = node.children || [];
254
- const imgChild = children.find((c) => c && c.type === 'JSXElement' && (getJsxName(c) === 'img' || getJsxName(c) === 'Image'));
255
- if (imgChild) {
256
- replaceAttrValue(imgChild, 'src', siteDataBinding(transform.field.split('.'), transform.fallback, 'image'));
257
- ensurePreviewPath(imgChild, transform.field);
258
- }
259
- ensurePreviewPath(node, transform.field);
260
- return;
261
- }
262
- replaceAttrValue(node, 'src', siteDataBinding(transform.field.split('.'), transform.fallback, 'image'));
263
- ensurePreviewPath(node, transform.field);
264
- return;
265
- }
266
- if (transform.operation === 'extract-alt') {
267
- replaceAttrValue(node, 'alt', siteDataBinding(transform.field.split('.'), transform.fallback, 'text'));
268
- return;
269
- }
270
- if (transform.operation === 'extract-placeholder') {
271
- replaceAttrValue(node, 'placeholder', siteDataBinding(transform.field.split('.'), transform.fallback, 'text'));
272
- ensurePreviewPath(node, transform.field);
273
- return;
274
- }
275
- if (transform.operation === 'extract-prop') {
276
- const propName = transform.propName || 'title';
277
- replaceAttrValue(node, propName, siteDataBinding(transform.field.split('.'), transform.fallback, transform.fieldType || 'text'));
278
- if (!hasJsxAttribute(node, 'data-preview-field-path') && !hasJsxAttribute(node, 'data-preview-list-path')) {
279
- ensurePreviewPath(node, transform.field);
280
- }
281
- return;
282
- }
283
- if (transform.operation === 'bind-button-with-icon') {
284
- ensurePreviewPath(node, transform.field);
285
- ensureStyleAttrs(node, transform.field, 'button');
286
- for (let i = 0; i < (node.children || []).length; i++) {
287
- const child = node.children[i];
288
- if (child.type === 'JSXText' && child.value.trim().length > 0) {
289
- node.children[i] = b.jsxExpressionContainer(
290
- siteDataBinding(transform.field.split('.'), child.value.trim(), 'text')
291
- );
292
- }
293
- }
294
- return;
295
- }
296
- if (transform.operation === 'bind-highlighted-heading') {
297
- ensurePreviewPath(node, transform.field);
298
- ensureStyleAttrs(node, transform.field, 'text');
299
- for (let i = 0; i < (node.children || []).length; i++) {
300
- const child = node.children[i];
301
- if (child.type === 'JSXText' && child.value.trim().length > 0) {
302
- node.children[i] = b.jsxExpressionContainer(
303
- siteDataBinding(transform.field.split('.'), child.value.trim(), 'text')
304
- );
305
- } else if (child.type === 'JSXElement') {
306
- const highlightField = `${transform.field}Highlight`;
307
- ensurePreviewPath(child, highlightField);
308
- for (let j = 0; j < (child.children || []).length; j++) {
309
- if (child.children[j].type === 'JSXText' && child.children[j].value.trim().length > 0) {
310
- child.children[j] = b.jsxExpressionContainer(
311
- siteDataBinding(highlightField.split('.'), child.children[j].value.trim(), 'text')
312
- );
313
- }
314
- }
315
- }
316
- }
317
- return;
318
- }
319
- if (transform.operation === 'prop-flow-callsite') {
320
- if (transform.propTransforms) {
321
- for (const [propName, propInfo] of Object.entries(transform.propTransforms)) {
322
- replaceAttrValue(
323
- node,
324
- propName,
325
- siteDataBinding(propInfo.field.split('.'), propInfo.fallback, propInfo.type || 'text')
326
- );
327
- }
328
- }
329
- if (transform.previewPath && !hasJsxAttribute(node, 'previewPath')) {
330
- node.openingElement.attributes.push(
331
- b.jsxAttribute(b.jsxIdentifier('previewPath'), b.stringLiteral(transform.previewPath))
332
- );
333
- }
334
- return;
335
- }
336
- if (transform.operation === 'wrap-text-span') {
337
- wrapLiteralTextChildren(node, transform.field, transform.fallback);
338
- return;
339
- }
340
- if (transform.operation === 'extract-text') {
341
- const tagName = getJsxName(node);
342
- if (BROAD_CONTENT_CONTAINERS.has(tagName)) {
343
- wrapLiteralTextChildren(node, transform.field, transform.fallback);
344
- return;
345
- }
346
- ensurePreviewPath(node, transform.field);
347
- ensureStyleAttrs(node, transform.field, inferButtonKind(transform.tag));
348
- replaceTextChildren(node, transform.field, transform.fallback, transform.fieldType || 'text');
349
- return;
350
- }
351
- if (transform.operation === 'style-bind') {
352
- if (transform.styleKind === 'grid') {
353
- const container = findAncestorJsxElement(pathNode) || node;
354
- ensureStyleAttrs(container, transform.stylePath, 'grid');
355
- return;
356
- }
357
- if (transform.styleKind === 'card') {
358
- ensureStyleAttrs(node, transform.stylePath, 'card');
359
- return;
360
- }
361
- ensureStyleAttrs(node, transform.stylePath, transform.styleKind || 'text');
362
- return;
363
- }
364
- if (transform.operation === 'extract-tailwind-bg') {
365
- const classAttr = findJsxAttribute(node, 'className') || findJsxAttribute(node, 'class');
366
- if (classAttr && classAttr.value) {
367
- if (classAttr.value.type === 'StringLiteral' || classAttr.value.type === 'Literal') {
368
- const cleaned = String(classAttr.value.value).replace(/\bbg-\[url\([^)]+\)\]\s*/g, '').trim();
369
- classAttr.value = b.stringLiteral(cleaned);
370
- }
371
- }
372
- const urlParts = transform.field.split('.');
373
- const styleExpr = b.objectExpression([
374
- b.property(
375
- 'init',
376
- b.identifier('backgroundImage'),
377
- b.templateLiteral(
378
- [
379
- b.templateElement({ raw: 'url(', cooked: 'url(' }, false),
380
- b.templateElement({ raw: ')', cooked: ')' }, true),
381
- ],
382
- [siteDataBinding(urlParts, transform.fallback || transform.bgUrl || '', 'image')]
383
- )
384
- ),
385
- ]);
386
- replaceAttrValue(node, 'style', styleExpr);
387
- ensurePreviewPath(node, transform.field);
388
- return;
389
- }
390
- }
391
-
392
- function findAncestorJsxElement(pathNode) {
393
- let current = pathNode.parent;
394
- while (current) {
395
- const candidate = current.node || current.value;
396
- if (candidate?.type === 'JSXElement') return candidate;
397
- current = current.parentPath || current.parent;
398
- }
399
- return null;
400
- }
401
-
402
- function inferButtonKind(tag) {
403
- if (tag === 'button' || tag === 'Button') return 'button';
404
- return 'text';
405
- }
406
-
407
3
  /**
408
- * Replaces literal text children with an editable <span>, leaving the container
409
- * element and every one of its classes untouched.
410
- */
411
- function wrapLiteralTextChildren(node, fieldPath, fallback) {
412
- const nextChildren = [];
413
- let wrapped = false;
414
-
415
- for (const child of node.children || []) {
416
- if (!child) continue;
417
- if (!wrapped && child.type === 'JSXText' && child.value.replace(/\s+/g, '').length) {
418
- const leading = child.value.match(/^\s*/)?.[0] || '';
419
- const trailing = child.value.match(/\s*$/)?.[0] || '';
420
- if (leading) nextChildren.push(b.jsxText(leading));
421
- nextChildren.push(wrapTextInEditableSpan(fieldPath, fallback, 'text'));
422
- if (trailing) nextChildren.push(b.jsxText(trailing));
423
- wrapped = true;
424
- continue;
425
- }
426
- if (child.type === 'JSXText' && child.value.replace(/\s+/g, '').length) continue;
427
- nextChildren.push(child);
428
- }
429
-
430
- if (wrapped) node.children = nextChildren;
431
- }
432
-
433
- function buildCompositeKeyAttribute(binding, indexName) {
434
- const fields = ['id', 'slug', 'title', 'name'];
435
- let expr = b.memberExpression(b.identifier(binding), b.identifier(fields[0]), false);
436
- for (let i = 1; i < fields.length; i++) {
437
- expr = b.logicalExpression(
438
- '||',
439
- expr,
440
- b.memberExpression(b.identifier(binding), b.identifier(fields[i]), false)
441
- );
442
- }
443
- expr = b.logicalExpression('||', expr, b.identifier(indexName));
444
- return b.jsxAttribute(b.jsxIdentifier('key'), b.jsxExpressionContainer(expr));
445
- }
446
-
447
- /**
448
- * Converts a literal-array `.map()` render into a Fivora list contract.
4
+ * Deneb ARC — Adaptive Refactoring Compiler
5
+ * Modular Transforms Facade
449
6
  *
450
- * The developer's array declaration becomes site-data backed with the original
451
- * literal as fallback, so `{item.title}` keeps working untouched. Markers are
452
- * emitted as JSX template literals (`items[${index}].title`) so added and
453
- * reordered items stay editable, which is what strict mode requires.
7
+ * This module delegates all transformation passes to the modular `transforms/` pipeline:
8
+ * - `transforms/transform-context.cjs`: AST traversal, node lookup, and attribute helpers
9
+ * - `transforms/primitives/`: Primitive text, image, link, and button transforms
10
+ * - `transforms/components/`: Reusable component, child card, and compound content transforms
11
+ * - `transforms/collections/`: `.map()` list and item path threading
12
+ * - `transforms/assets/`: Tailwind background images and media transforms
13
+ * - `transforms/routing/`: Route page key inference and layout provider wrapping
14
+ * - `transforms/runtime/`: Provider hooks and site data context rewrite
15
+ * - `transforms/healing/`: Marker sanitization and empty-state/modal conditionals
16
+ * - `transforms/element-transform.cjs`: Element-level AST mutation dispatcher
17
+ * - `transforms/index.cjs`: File-level plan execution orchestrator
454
18
  */
455
- function applyCollectionTransform(ast, transform, isClient = false, isTypeScript = false) {
456
- const listPath = transform.listField;
457
- const binding = transform.itemParam;
458
- if (!listPath || !binding) return false;
459
-
460
- const mapCall = findMapCall(ast, transform.loc);
461
- if (!mapCall) return false;
462
-
463
- const callback = (mapCall.node.arguments || [])[0];
464
- if (!callback) return false;
465
-
466
- // The contract needs a concrete index for each item marker.
467
- let indexName = transform.indexParam;
468
- if (!indexName) {
469
- if (callback.params && callback.params.length >= 2 && callback.params[1]?.type === 'Identifier') {
470
- indexName = callback.params[1].name;
471
- } else {
472
- indexName = callback.params.some((p) => p?.name === 'index') ? 'denebIndex' : 'index';
473
- const indexId = b.identifier(indexName);
474
- if (isTypeScript) {
475
- indexId.typeAnnotation = b.tsTypeAnnotation(b.tsNumberKeyword());
476
- }
477
- callback.params.push(indexId);
478
- }
479
- }
480
-
481
- // In TypeScript files, prevent TS7006 implicit any on callback parameters
482
- if (isTypeScript && callback.params && callback.params.length > 0) {
483
- const itemParam = callback.params[0];
484
- if (itemParam && !itemParam.typeAnnotation) {
485
- itemParam.typeAnnotation = b.tsTypeAnnotation(b.tsAnyKeyword());
486
- }
487
- if (callback.params.length >= 2) {
488
- const idxParam = callback.params[1];
489
- if (idxParam && !idxParam.typeAnnotation) {
490
- idxParam.typeAnnotation = b.tsTypeAnnotation(b.tsNumberKeyword());
491
- }
492
- }
493
- }
494
-
495
- const itemRoot = findElementByLoc(ast, transform.loc);
496
- if (!itemRoot) return false;
497
-
498
- const rootTagName = getJsxName(itemRoot.node);
499
- const isCustomChild = /^[A-Z]/.test(rootTagName);
500
-
501
- if (isCustomChild) {
502
- if (!hasJsxAttribute(itemRoot.node, 'previewItemPath')) {
503
- itemRoot.node.openingElement.attributes.push(
504
- jsxTemplatePathAttr('previewItemPath', listPath, indexName)
505
- );
506
- }
507
- if (!hasJsxAttribute(itemRoot.node, 'index')) {
508
- itemRoot.node.openingElement.attributes.push(
509
- b.jsxAttribute(b.jsxIdentifier('index'), b.jsxExpressionContainer(b.identifier(indexName)))
510
- );
511
- }
512
- }
513
-
514
- if (!hasJsxAttribute(itemRoot.node, 'data-preview-item-path')) {
515
- itemRoot.node.openingElement.attributes.push(
516
- jsxTemplatePathAttr('data-preview-item-path', listPath, indexName)
517
- );
518
- }
519
- if (!hasJsxAttribute(itemRoot.node, 'data-preview-style-target')) {
520
- itemRoot.node.openingElement.attributes.push(
521
- jsxTemplatePathAttr('data-preview-style-target', listPath, indexName, '.card')
522
- );
523
- itemRoot.node.openingElement.attributes.push(
524
- b.jsxAttribute(b.jsxIdentifier('data-preview-style-type'), b.stringLiteral('card'))
525
- );
526
- }
527
- if (!hasJsxAttribute(itemRoot.node, 'key')) {
528
- itemRoot.node.openingElement.attributes.push(
529
- buildCompositeKeyAttribute(binding, indexName)
530
- );
531
- }
532
-
533
- if (transform.isPrimitiveArray) {
534
- markPrimitiveItemFields(callback, { listPath, binding, indexName });
535
- } else {
536
- markItemFields(callback, { listPath, binding, indexName });
537
- }
538
- if (!isCustomChild) {
539
- markComponentRefItemsStatic(callback, binding);
540
- }
541
-
542
- const container = findListContainer(mapCall);
543
- if (container && !hasJsxAttribute(container, 'data-preview-list-path')) {
544
- container.openingElement.attributes.push(
545
- b.jsxAttribute(b.jsxIdentifier('data-preview-list-path'), b.stringLiteral(listPath))
546
- );
547
- }
548
- if (container && !hasJsxAttribute(container, 'data-preview-style-target')) {
549
- container.openingElement.attributes.push(
550
- b.jsxAttribute(b.jsxIdentifier('data-preview-style-target'), b.stringLiteral(`${listPath}.grid`))
551
- );
552
- container.openingElement.attributes.push(
553
- b.jsxAttribute(b.jsxIdentifier('data-preview-style-type'), b.stringLiteral('grid'))
554
- );
555
- }
556
-
557
- return bindArrayDeclaration(ast, mapCall, listPath, isClient, Boolean(transform.hasComponentRef), isTypeScript);
558
- }
559
-
560
- function findMapCall(ast, loc) {
561
- let found = null;
562
- recast.types.visit(ast, {
563
- visitCallExpression(pathNode) {
564
- const callee = pathNode.node.callee;
565
- if (
566
- !found &&
567
- callee?.type === 'MemberExpression' &&
568
- !callee.computed &&
569
- callee.property?.name === 'map'
570
- ) {
571
- let hit = false;
572
- recast.types.visit(pathNode.node, {
573
- visitJSXElement(inner) {
574
- if (locKey(inner.node) === loc) {
575
- hit = true;
576
- return false;
577
- }
578
- this.traverse(inner);
579
- },
580
- });
581
- if (hit) {
582
- found = pathNode;
583
- return false;
584
- }
585
- }
586
- this.traverse(pathNode);
587
- },
588
- });
589
- return found;
590
- }
591
-
592
- /** Attaches field markers to the elements that render item properties. */
593
- function markItemFields(callback, { listPath, binding, indexName }) {
594
- recast.types.visit(callback, {
595
- visitJSXElement(pathNode) {
596
- const node = pathNode.node;
597
-
598
- for (const attr of node.openingElement.attributes || []) {
599
- if (attr.type !== 'JSXAttribute' || !attr.value) continue;
600
- const attrName = attr.name?.name;
601
- if (attrName !== 'src' && attrName !== 'href') continue;
602
- const property = itemMemberName(attr.value.expression, binding);
603
- if (!property) continue;
604
- if (!hasJsxAttribute(node, 'data-preview-field-path')) {
605
- node.openingElement.attributes.push(
606
- jsxTemplatePathAttr('data-preview-field-path', listPath, indexName, `.${property}`)
607
- );
608
- }
609
- }
610
-
611
- const textChild = (node.children || []).find(
612
- (child) => child.type === 'JSXExpressionContainer' && itemMemberName(child.expression, binding)
613
- );
614
- if (textChild && !hasJsxAttribute(node, 'data-preview-field-path')) {
615
- const property = itemMemberName(textChild.expression, binding);
616
- const tagName = getJsxName(node);
617
- if (BROAD_CONTENT_CONTAINERS.has(tagName) || BROAD_CONTENT_CONTAINERS.has(String(tagName).toLowerCase())) {
618
- wrapItemFieldInSpan(node, textChild, listPath, indexName, property);
619
- } else {
620
- node.openingElement.attributes.push(
621
- jsxTemplatePathAttr('data-preview-field-path', listPath, indexName, `.${property}`)
622
- );
623
- }
624
- }
625
-
626
- this.traverse(pathNode);
627
- },
628
- });
629
- }
630
-
631
- function wrapItemFieldInSpan(node, textChild, listPath, indexName, property) {
632
- const nextChildren = [];
633
- for (const child of node.children || []) {
634
- if (child === textChild) {
635
- nextChildren.push(
636
- b.jsxElement(
637
- b.jsxOpeningElement(
638
- b.jsxIdentifier('span'),
639
- [jsxTemplatePathAttr('data-preview-field-path', listPath, indexName, `.${property}`)],
640
- false
641
- ),
642
- b.jsxClosingElement(b.jsxIdentifier('span')),
643
- [child],
644
- false
645
- )
646
- );
647
- } else {
648
- nextChildren.push(child);
649
- }
650
- }
651
- node.children = nextChildren;
652
- }
653
-
654
- function markComponentRefItemsStatic(callback, binding) {
655
- recast.types.visit(callback, {
656
- visitJSXElement(pathNode) {
657
- const name = pathNode.node.openingElement && pathNode.node.openingElement.name;
658
- if (
659
- name &&
660
- name.type === 'JSXMemberExpression' &&
661
- name.object &&
662
- (name.object.type === 'Identifier' || name.object.type === 'JSXIdentifier') &&
663
- name.object.name === binding
664
- ) {
665
- if (!hasJsxAttribute(pathNode.node, 'data-preview-static') && !hasJsxAttribute(pathNode.node, 'data-preview-field-path')) {
666
- pathNode.node.openingElement.attributes.push(
667
- b.jsxAttribute(b.jsxIdentifier('data-preview-static'), b.stringLiteral('component-ref'))
668
- );
669
- }
670
- }
671
- this.traverse(pathNode);
672
- },
673
- });
674
- }
675
-
676
- function itemMemberName(expr, binding) {
677
- return extractItemMemberName(expr, binding);
678
- }
679
-
680
- function isDirectItemExpr(expr, binding) {
681
- if (!expr) return false;
682
- const unwrapped = unwrapExpr(expr);
683
- return Boolean(
684
- unwrapped &&
685
- (unwrapped.type === 'Identifier' || unwrapped.type === 'JSXIdentifier') &&
686
- unwrapped.name === binding
687
- );
688
- }
689
-
690
- function markPrimitiveItemFields(callback, { listPath, binding, indexName }) {
691
- recast.types.visit(callback, {
692
- visitJSXElement(pathNode) {
693
- const node = pathNode.node;
694
- const textChild = (node.children || []).find(
695
- (child) => child.type === 'JSXExpressionContainer' && isDirectItemExpr(child.expression, binding)
696
- );
697
- if (textChild && !hasJsxAttribute(node, 'data-preview-field-path')) {
698
- const tagName = getJsxName(node);
699
- if (
700
- node.children.length === 1 &&
701
- (tagName === 'span' || tagName === 'p' || tagName === 'li') &&
702
- !hasJsxAttribute(node, 'data-preview-item-path')
703
- ) {
704
- node.openingElement.attributes.push(
705
- jsxTemplatePathAttr('data-preview-field-path', listPath, indexName, '')
706
- );
707
- } else {
708
- wrapPrimitiveItemFieldInSpan(node, textChild, listPath, indexName);
709
- }
710
- }
711
- this.traverse(pathNode);
712
- },
713
- });
714
- }
715
-
716
- function wrapPrimitiveItemFieldInSpan(node, textChild, listPath, indexName) {
717
- const nextChildren = [];
718
- for (const child of node.children || []) {
719
- if (child === textChild) {
720
- nextChildren.push(
721
- b.jsxElement(
722
- b.jsxOpeningElement(
723
- b.jsxIdentifier('span'),
724
- [jsxTemplatePathAttr('data-preview-field-path', listPath, indexName, '')],
725
- false
726
- ),
727
- b.jsxClosingElement(b.jsxIdentifier('span')),
728
- [child],
729
- false
730
- )
731
- );
732
- } else {
733
- nextChildren.push(child);
734
- }
735
- }
736
- node.children = nextChildren;
737
- }
738
-
739
- function instrumentChildCardComponent(ast, transform, isTypeScript = false) {
740
- const componentName = transform.componentName;
741
- const listPath = transform.listField || transform.listPath || '';
742
- if (!componentName) return false;
743
-
744
- let instrumented = false;
745
-
746
- function updateTsInterface(rootAst, interfaceName) {
747
- recast.types.visit(rootAst, {
748
- visitTSInterfaceDeclaration(pathNode) {
749
- if (pathNode.node.id?.name === interfaceName) {
750
- const body = pathNode.node.body?.body || [];
751
- if (!body.some((prop) => prop.key?.name === 'previewItemPath')) {
752
- const sig1 = b.tsPropertySignature(b.identifier('previewItemPath'), b.tsTypeAnnotation(b.tsStringKeyword()));
753
- sig1.optional = true;
754
- body.push(sig1);
755
- instrumented = true;
756
- }
757
- if (!body.some((prop) => prop.key?.name === 'index')) {
758
- const sig2 = b.tsPropertySignature(b.identifier('index'), b.tsTypeAnnotation(b.tsNumberKeyword()));
759
- sig2.optional = true;
760
- body.push(sig2);
761
- instrumented = true;
762
- }
763
- }
764
- this.traverse(pathNode);
765
- },
766
- visitTSTypeAliasDeclaration(pathNode) {
767
- if (pathNode.node.id?.name === interfaceName && pathNode.node.typeAnnotation?.type === 'TSTypeLiteral') {
768
- const members = pathNode.node.typeAnnotation.members || [];
769
- if (!members.some((m) => m.key?.name === 'previewItemPath')) {
770
- const sig1 = b.tsPropertySignature(b.identifier('previewItemPath'), b.tsTypeAnnotation(b.tsStringKeyword()));
771
- sig1.optional = true;
772
- members.push(sig1);
773
- instrumented = true;
774
- }
775
- if (!members.some((m) => m.key?.name === 'index')) {
776
- const sig2 = b.tsPropertySignature(b.identifier('index'), b.tsTypeAnnotation(b.tsNumberKeyword()));
777
- sig2.optional = true;
778
- members.push(sig2);
779
- instrumented = true;
780
- }
781
- }
782
- this.traverse(pathNode);
783
- },
784
- });
785
- }
786
-
787
- function instrumentFunctionProps(fnNode, isTs) {
788
- let changed = false;
789
- let params = fnNode.params || [];
790
- if (!params.length) {
791
- fnNode.params = [
792
- b.objectPattern([
793
- b.property('init', b.identifier('previewItemPath'), b.identifier('previewItemPath')),
794
- b.property('init', b.identifier('index'), b.identifier('index')),
795
- ]),
796
- ];
797
- changed = true;
798
- } else {
799
- const firstParam = params[0];
800
- if (firstParam.type === 'ObjectPattern') {
801
- const hasPreviewPath = firstParam.properties.some(
802
- (p) => p.key?.name === 'previewItemPath' || p.argument?.name === 'previewItemPath'
803
- );
804
- if (!hasPreviewPath) {
805
- const prop = b.property('init', b.identifier('previewItemPath'), b.identifier('previewItemPath'));
806
- prop.shorthand = true;
807
- firstParam.properties.push(prop);
808
- changed = true;
809
- }
810
- const hasIndex = firstParam.properties.some(
811
- (p) => p.key?.name === 'index' || p.argument?.name === 'index'
812
- );
813
- if (!hasIndex) {
814
- const prop = b.property('init', b.identifier('index'), b.identifier('index'));
815
- prop.shorthand = true;
816
- firstParam.properties.push(prop);
817
- changed = true;
818
- }
819
-
820
- if (isTs && firstParam.typeAnnotation?.typeAnnotation) {
821
- const typeAnn = firstParam.typeAnnotation.typeAnnotation;
822
- if (typeAnn.type === 'TSTypeReference' && typeAnn.typeName?.name) {
823
- updateTsInterface(ast, typeAnn.typeName.name);
824
- }
825
- }
826
- } else if (firstParam.type === 'Identifier') {
827
- const paramName = firstParam.name;
828
- if (isTs && firstParam.typeAnnotation?.typeAnnotation) {
829
- const typeAnn = firstParam.typeAnnotation.typeAnnotation;
830
- if (typeAnn.type === 'TSTypeReference' && typeAnn.typeName?.name) {
831
- updateTsInterface(ast, typeAnn.typeName.name);
832
- }
833
- }
834
- recast.types.visit(fnNode.body || fnNode, {
835
- visitVariableDeclarator(vPath) {
836
- if (vPath.node.id?.type === 'ObjectPattern' && vPath.node.init?.name === paramName) {
837
- const propsList = vPath.node.id.properties;
838
- if (!propsList.some((p) => p.key?.name === 'previewItemPath')) {
839
- const prop = b.property('init', b.identifier('previewItemPath'), b.identifier('previewItemPath'));
840
- prop.shorthand = true;
841
- propsList.push(prop);
842
- changed = true;
843
- }
844
- if (!propsList.some((p) => p.key?.name === 'index')) {
845
- const prop = b.property('init', b.identifier('index'), b.identifier('index'));
846
- prop.shorthand = true;
847
- propsList.push(prop);
848
- changed = true;
849
- }
850
- }
851
- this.traverse(vPath);
852
- },
853
- });
854
- }
855
- }
856
- return changed;
857
- }
858
-
859
- recast.types.visit(ast, {
860
- visitFunctionDeclaration(pathNode) {
861
- if (pathNode.node.id?.name === componentName) {
862
- if (instrumentFunctionProps(pathNode.node, isTypeScript)) instrumented = true;
863
- }
864
- this.traverse(pathNode);
865
- },
866
- visitVariableDeclarator(pathNode) {
867
- if (pathNode.node.id?.name === componentName) {
868
- const init = pathNode.node.init;
869
- if (init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) {
870
- if (instrumentFunctionProps(init, isTypeScript)) instrumented = true;
871
- }
872
- }
873
- this.traverse(pathNode);
874
- },
875
- visitExportDefaultDeclaration(pathNode) {
876
- const decl = pathNode.node.declaration;
877
- if (decl && (decl.type === 'FunctionDeclaration' || decl.type === 'ArrowFunctionExpression' || decl.type === 'FunctionExpression')) {
878
- const fnName = decl.id?.name;
879
- if (!fnName || fnName === componentName) {
880
- if (instrumentFunctionProps(decl, isTypeScript)) instrumented = true;
881
- }
882
- }
883
- this.traverse(pathNode);
884
- },
885
- });
886
-
887
- let rootElement = null;
888
- recast.types.visit(ast, {
889
- visitReturnStatement(pathNode) {
890
- if (!rootElement && pathNode.node.argument) {
891
- let arg = pathNode.node.argument;
892
- if (arg.type === 'ParenthesizedExpression') arg = arg.expression;
893
- if (arg.type === 'JSXElement') {
894
- rootElement = arg;
895
- }
896
- }
897
- this.traverse(pathNode);
898
- },
899
- });
900
-
901
- if (rootElement) {
902
- if (!hasJsxAttribute(rootElement, 'data-preview-item-path')) {
903
- if (listPath) {
904
- rootElement.openingElement.attributes.push(
905
- jsxTemplatePathAttr('data-preview-item-path', listPath, 'index')
906
- );
907
- } else {
908
- rootElement.openingElement.attributes.push(
909
- b.jsxAttribute(
910
- b.jsxIdentifier('data-preview-item-path'),
911
- b.jsxExpressionContainer(b.identifier('previewItemPath'))
912
- )
913
- );
914
- }
915
- instrumented = true;
916
- }
917
- }
918
-
919
- recast.types.visit(ast, {
920
- visitJSXElement(pathNode) {
921
- const node = pathNode.node;
922
- const tagName = getJsxName(node);
923
-
924
- const srcAttr = findJsxAttribute(node, 'src');
925
- if (srcAttr && (tagName === 'img' || tagName === 'Image') && !hasJsxAttribute(node, 'data-preview-field-path')) {
926
- const rawCode = recast.print(srcAttr.value).code;
927
- if (/image|photo|avatar|thumb|img|src/i.test(rawCode)) {
928
- const imgField = (transform.itemFields || []).find((f) => f.type === 'image' || /image|photo/i.test(f.key))?.key || 'image';
929
- if (listPath) {
930
- node.openingElement.attributes.push(
931
- jsxTemplatePathAttr('data-preview-field-path', listPath, 'index', `.${imgField}`)
932
- );
933
- } else {
934
- node.openingElement.attributes.push(
935
- b.jsxAttribute(
936
- b.jsxIdentifier('data-preview-field-path'),
937
- b.jsxExpressionContainer(
938
- b.templateLiteral(
939
- [
940
- b.templateElement({ raw: '', cooked: '' }, false),
941
- b.templateElement({ raw: `.${imgField}`, cooked: `.${imgField}` }, true),
942
- ],
943
- [b.identifier('previewItemPath')]
944
- )
945
- )
946
- )
947
- );
948
- }
949
- instrumented = true;
950
- }
951
- }
952
-
953
- for (const child of node.children || []) {
954
- if (child.type === 'JSXExpressionContainer') {
955
- const exprCode = recast.print(child.expression).code;
956
- if (!hasJsxAttribute(node, 'data-preview-field-path')) {
957
- if (/name|title|heading/i.test(exprCode)) {
958
- const nameField = (transform.itemFields || []).find((f) => /name|title|heading/i.test(f.key))?.key || 'name';
959
- if (listPath) {
960
- node.openingElement.attributes.push(
961
- jsxTemplatePathAttr('data-preview-field-path', listPath, 'index', `.${nameField}`)
962
- );
963
- } else {
964
- node.openingElement.attributes.push(
965
- b.jsxAttribute(
966
- b.jsxIdentifier('data-preview-field-path'),
967
- b.jsxExpressionContainer(
968
- b.templateLiteral(
969
- [
970
- b.templateElement({ raw: '', cooked: '' }, false),
971
- b.templateElement({ raw: `.${nameField}`, cooked: `.${nameField}` }, true),
972
- ],
973
- [b.identifier('previewItemPath')]
974
- )
975
- )
976
- )
977
- );
978
- }
979
- instrumented = true;
980
- break;
981
- } else if (/price|cost|amount/i.test(exprCode)) {
982
- const priceField = (transform.itemFields || []).find((f) => /price|cost/i.test(f.key))?.key || 'price';
983
- if (listPath) {
984
- node.openingElement.attributes.push(
985
- jsxTemplatePathAttr('data-preview-field-path', listPath, 'index', `.${priceField}`)
986
- );
987
- } else {
988
- node.openingElement.attributes.push(
989
- b.jsxAttribute(
990
- b.jsxIdentifier('data-preview-field-path'),
991
- b.jsxExpressionContainer(
992
- b.templateLiteral(
993
- [
994
- b.templateElement({ raw: '', cooked: '' }, false),
995
- b.templateElement({ raw: `.${priceField}`, cooked: `.${priceField}` }, true),
996
- ],
997
- [b.identifier('previewItemPath')]
998
- )
999
- )
1000
- )
1001
- );
1002
- }
1003
- instrumented = true;
1004
- break;
1005
- } else if (/description|desc|subtitle/i.test(exprCode)) {
1006
- const descField = (transform.itemFields || []).find((f) => /desc/i.test(f.key))?.key || 'description';
1007
- if (listPath) {
1008
- node.openingElement.attributes.push(
1009
- jsxTemplatePathAttr('data-preview-field-path', listPath, 'index', `.${descField}`)
1010
- );
1011
- } else {
1012
- node.openingElement.attributes.push(
1013
- b.jsxAttribute(
1014
- b.jsxIdentifier('data-preview-field-path'),
1015
- b.jsxExpressionContainer(
1016
- b.templateLiteral(
1017
- [
1018
- b.templateElement({ raw: '', cooked: '' }, false),
1019
- b.templateElement({ raw: `.${descField}`, cooked: `.${descField}` }, true),
1020
- ],
1021
- [b.identifier('previewItemPath')]
1022
- )
1023
- )
1024
- )
1025
- );
1026
- }
1027
- instrumented = true;
1028
- break;
1029
- }
1030
- }
1031
- }
1032
- }
1033
- this.traverse(pathNode);
1034
- },
1035
- });
1036
-
1037
- return instrumented;
1038
- }
1039
-
1040
- function instrumentReusableComponent(ast, transform, isTypeScript = false) {
1041
- const componentName = transform.componentName;
1042
- const propTransforms = transform.propTransforms || {};
1043
- const propNames = Object.keys(propTransforms);
1044
- if (!propNames.length) return false;
1045
-
1046
- let instrumented = false;
1047
-
1048
- function updateTsInterface(rootAst, interfaceName) {
1049
- recast.types.visit(rootAst, {
1050
- visitTSInterfaceDeclaration(pathNode) {
1051
- if (pathNode.node.id?.name === interfaceName) {
1052
- const body = pathNode.node.body?.body || [];
1053
- if (!body.some((m) => m.key?.name === 'previewPath')) {
1054
- const sig = b.tsPropertySignature(b.identifier('previewPath'), b.tsTypeAnnotation(b.tsStringKeyword()));
1055
- sig.optional = true;
1056
- body.push(sig);
1057
- instrumented = true;
1058
- }
1059
- }
1060
- this.traverse(pathNode);
1061
- },
1062
- visitTSTypeAliasDeclaration(pathNode) {
1063
- if (pathNode.node.id?.name === interfaceName && pathNode.node.typeAnnotation?.type === 'TSTypeLiteral') {
1064
- const members = pathNode.node.typeAnnotation.members || [];
1065
- if (!members.some((m) => m.key?.name === 'previewPath')) {
1066
- const sig = b.tsPropertySignature(b.identifier('previewPath'), b.tsTypeAnnotation(b.tsStringKeyword()));
1067
- sig.optional = true;
1068
- members.push(sig);
1069
- instrumented = true;
1070
- }
1071
- }
1072
- this.traverse(pathNode);
1073
- },
1074
- });
1075
- }
1076
-
1077
- function instrumentFunctionProps(fnNode, isTs) {
1078
- let changed = false;
1079
- let params = fnNode.params || [];
1080
- if (!params.length) {
1081
- fnNode.params = [
1082
- b.objectPattern([
1083
- b.property('init', b.identifier('previewPath'), b.identifier('previewPath')),
1084
- ]),
1085
- ];
1086
- changed = true;
1087
- } else {
1088
- const firstParam = params[0];
1089
- if (firstParam.type === 'ObjectPattern') {
1090
- const hasPreviewPath = firstParam.properties.some(
1091
- (p) => p.key?.name === 'previewPath' || p.argument?.name === 'previewPath'
1092
- );
1093
- if (!hasPreviewPath) {
1094
- const prop = b.property('init', b.identifier('previewPath'), b.identifier('previewPath'));
1095
- prop.shorthand = true;
1096
- firstParam.properties.push(prop);
1097
- changed = true;
1098
- }
1099
- if (isTs && firstParam.typeAnnotation?.typeAnnotation) {
1100
- const typeAnn = firstParam.typeAnnotation.typeAnnotation;
1101
- if (typeAnn.type === 'TSTypeReference' && typeAnn.typeName?.name) {
1102
- updateTsInterface(ast, typeAnn.typeName.name);
1103
- }
1104
- }
1105
- } else if (firstParam.type === 'Identifier') {
1106
- const paramName = firstParam.name;
1107
- if (isTs && firstParam.typeAnnotation?.typeAnnotation) {
1108
- const typeAnn = firstParam.typeAnnotation.typeAnnotation;
1109
- if (typeAnn.type === 'TSTypeReference' && typeAnn.typeName?.name) {
1110
- updateTsInterface(ast, typeAnn.typeName.name);
1111
- }
1112
- }
1113
- recast.types.visit(fnNode.body || fnNode, {
1114
- visitVariableDeclarator(vPath) {
1115
- if (vPath.node.id?.type === 'ObjectPattern' && vPath.node.init?.name === paramName) {
1116
- const propsList = vPath.node.id.properties;
1117
- if (!propsList.some((p) => p.key?.name === 'previewPath')) {
1118
- const prop = b.property('init', b.identifier('previewPath'), b.identifier('previewPath'));
1119
- prop.shorthand = true;
1120
- propsList.push(prop);
1121
- changed = true;
1122
- }
1123
- }
1124
- this.traverse(vPath);
1125
- },
1126
- });
1127
- }
1128
- }
1129
- return changed;
1130
- }
1131
-
1132
- recast.types.visit(ast, {
1133
- visitFunctionDeclaration(pathNode) {
1134
- if (!componentName || pathNode.node.id?.name === componentName) {
1135
- if (instrumentFunctionProps(pathNode.node, isTypeScript)) instrumented = true;
1136
- }
1137
- this.traverse(pathNode);
1138
- },
1139
- visitVariableDeclarator(pathNode) {
1140
- if (pathNode.node.id?.name === componentName) {
1141
- const init = pathNode.node.init;
1142
- if (init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) {
1143
- if (instrumentFunctionProps(init, isTypeScript)) instrumented = true;
1144
- }
1145
- }
1146
- this.traverse(pathNode);
1147
- },
1148
- visitExportDefaultDeclaration(pathNode) {
1149
- const decl = pathNode.node.declaration;
1150
- if (decl && (decl.type === 'FunctionDeclaration' || decl.type === 'ArrowFunctionExpression' || decl.type === 'FunctionExpression')) {
1151
- const fnName = decl.id?.name;
1152
- if (!fnName || fnName === componentName) {
1153
- if (instrumentFunctionProps(decl, isTypeScript)) instrumented = true;
1154
- }
1155
- }
1156
- this.traverse(pathNode);
1157
- },
1158
- });
1159
-
1160
- recast.types.visit(ast, {
1161
- visitJSXElement(pathNode) {
1162
- const node = pathNode.node;
1163
- for (const child of node.children || []) {
1164
- if (child.type === 'JSXExpressionContainer') {
1165
- const expr = child.expression;
1166
- let propMatch = null;
1167
- if (expr && expr.type === 'Identifier' && propNames.includes(expr.name)) {
1168
- propMatch = expr.name;
1169
- } else if (expr && expr.type === 'LogicalExpression' && expr.left?.name && propNames.includes(expr.left.name)) {
1170
- propMatch = expr.left.name;
1171
- }
1172
- if (propMatch && !hasJsxAttribute(node, 'data-preview-field-path')) {
1173
- const previewAttr = b.jsxAttribute(
1174
- b.jsxIdentifier('data-preview-field-path'),
1175
- b.jsxExpressionContainer(
1176
- b.conditionalExpression(
1177
- b.identifier('previewPath'),
1178
- b.templateLiteral(
1179
- [
1180
- b.templateElement({ raw: '', cooked: '' }, false),
1181
- b.templateElement({ raw: `.${propMatch}`, cooked: `.${propMatch}` }, true),
1182
- ],
1183
- [b.identifier('previewPath')]
1184
- ),
1185
- b.identifier('undefined')
1186
- )
1187
- )
1188
- );
1189
- node.openingElement.attributes.push(previewAttr);
1190
- instrumented = true;
1191
- break;
1192
- }
1193
- }
1194
- }
1195
- this.traverse(pathNode);
1196
- },
1197
- });
1198
-
1199
- return instrumented;
1200
- }
1201
-
1202
- /** The nearest JSX element that wraps the map expression. */
1203
- function findListContainer(mapCallPath) {
1204
- let current = mapCallPath.parent;
1205
- while (current) {
1206
- const node = current.node || current.value;
1207
- if (node?.type === 'JSXElement') return node;
1208
- current = current.parentPath || current.parent;
1209
- }
1210
- return null;
1211
- }
1212
-
1213
- function isModuleLevel(pathNode) {
1214
- let current = pathNode.parentPath || pathNode.parent;
1215
- while (current) {
1216
- const type = current.node?.type;
1217
- if (
1218
- type === 'FunctionDeclaration' ||
1219
- type === 'FunctionExpression' ||
1220
- type === 'ArrowFunctionExpression'
1221
- ) {
1222
- return false;
1223
- }
1224
- if (type === 'Program') return true;
1225
- current = current.parentPath || current.parent;
1226
- }
1227
- return true;
1228
- }
1229
-
1230
- function findEnclosingFunction(pathNode) {
1231
- let current = pathNode.parentPath || pathNode.parent;
1232
- while (current) {
1233
- const type = current.node?.type;
1234
- if (
1235
- type === 'FunctionDeclaration' ||
1236
- type === 'FunctionExpression' ||
1237
- type === 'ArrowFunctionExpression'
1238
- ) {
1239
- return current;
1240
- }
1241
- current = current.parentPath || current.parent;
1242
- }
1243
- return null;
1244
- }
1245
-
1246
- function bindArrayDeclaration(ast, mapCallPath, listPath, isClient = false, mergeDefaultRefs = false, isTypeScript = false) {
1247
- const arrayName = mapCallPath.node.callee.object?.name;
1248
- if (!arrayName) return false;
1249
- let bound = false;
1250
-
1251
- recast.types.visit(ast, {
1252
- visitVariableDeclarator(pathNode) {
1253
- const node = pathNode.node;
1254
- if (bound || node.id?.type !== 'Identifier' || node.id.name !== arrayName) {
1255
- this.traverse(pathNode);
1256
- return;
1257
- }
1258
- if (node.init?.type !== 'ArrayExpression') {
1259
- this.traverse(pathNode);
1260
- return;
1261
- }
1262
-
1263
- if (isClient && isModuleLevel(pathNode)) {
1264
- const defaultName = 'DEFAULT_' + arrayName;
1265
- node.id.name = defaultName;
1266
- const fnPath = findEnclosingFunction(mapCallPath);
1267
- if (fnPath && fnPath.node.body?.type === 'BlockStatement') {
1268
- const body = fnPath.node.body.body;
1269
- const already = body.some((stmt) => recast.print(stmt).code.includes(`const ${arrayName} =`));
1270
- if (!already) {
1271
- const localId = b.identifier(arrayName);
1272
- if (isTypeScript) {
1273
- localId.typeAnnotation = b.tsTypeAnnotation(b.tsArrayType(b.tsAnyKeyword()));
1274
- }
1275
- const localDecl = b.variableDeclaration('const', [
1276
- b.variableDeclarator(
1277
- localId,
1278
- mergeDefaultRefs
1279
- ? mergeDefaultItemRefsBinding(listPath.split('.'), defaultName)
1280
- : siteDataListBinding(listPath.split('.'), b.identifier(defaultName))
1281
- ),
1282
- ]);
1283
- const hookIdx = body.findIndex((stmt) => recast.print(stmt).code.includes('useSiteData'));
1284
- if (hookIdx >= 0) {
1285
- body.splice(hookIdx + 1, 0, localDecl);
1286
- } else {
1287
- body.unshift(localDecl);
1288
- }
1289
- }
1290
- }
1291
- bound = true;
1292
- return false;
1293
- }
1294
-
1295
- if (isTypeScript && node.id?.type === 'Identifier' && !node.id.typeAnnotation) {
1296
- node.id.typeAnnotation = b.tsTypeAnnotation(b.tsArrayType(b.tsAnyKeyword()));
1297
- }
1298
- node.init = siteDataListBinding(listPath.split('.'), node.init);
1299
- bound = true;
1300
- return false;
1301
- },
1302
- });
1303
-
1304
- if (!bound) {
1305
- recast.types.visit(ast, {
1306
- visitImportDeclaration(pathNode) {
1307
- if (bound) return false;
1308
- const node = pathNode.node;
1309
- const spec = (node.specifiers || []).find(
1310
- (s) => (s.local?.name || s.imported?.name) === arrayName
1311
- );
1312
- if (!spec) {
1313
- this.traverse(pathNode);
1314
- return;
1315
- }
1316
-
1317
- const defaultName = 'DEFAULT_' + arrayName;
1318
- if (spec.type === 'ImportSpecifier') {
1319
- spec.local = b.identifier(defaultName);
1320
- } else if (spec.type === 'ImportDefaultSpecifier') {
1321
- spec.local = b.identifier(defaultName);
1322
- }
1323
-
1324
- const fnPath = findEnclosingFunction(mapCallPath);
1325
- if (fnPath && fnPath.node.body?.type === 'BlockStatement') {
1326
- const body = fnPath.node.body.body;
1327
- const already = body.some(
1328
- (stmt) =>
1329
- recast.print(stmt).code.includes(`const ${arrayName} =`) ||
1330
- recast.print(stmt).code.includes(`const ${arrayName}:`)
1331
- );
1332
- if (!already) {
1333
- const localId = b.identifier(arrayName);
1334
- if (isTypeScript) {
1335
- localId.typeAnnotation = b.tsTypeAnnotation(b.tsArrayType(b.tsAnyKeyword()));
1336
- }
1337
- const localDecl = b.variableDeclaration('const', [
1338
- b.variableDeclarator(
1339
- localId,
1340
- mergeDefaultRefs
1341
- ? mergeDefaultItemRefsBinding(listPath.split('.'), defaultName)
1342
- : siteDataListBinding(listPath.split('.'), b.identifier(defaultName))
1343
- ),
1344
- ]);
1345
- const hookIdx = body.findIndex((stmt) => recast.print(stmt).code.includes('useSiteData'));
1346
- if (hookIdx >= 0) {
1347
- body.splice(hookIdx + 1, 0, localDecl);
1348
- } else {
1349
- body.unshift(localDecl);
1350
- }
1351
- }
1352
- }
1353
- bound = true;
1354
- return false;
1355
- },
1356
- });
1357
- }
1358
-
1359
- return bound;
1360
- }
1361
-
1362
- function mergeDefaultItemRefsBinding(pathParts, defaultName) {
1363
- const listExpr = siteDataListBinding(pathParts, b.identifier(defaultName));
1364
- return b.callExpression(
1365
- b.memberExpression(listExpr, b.identifier('map'), false),
1366
- [
1367
- b.arrowFunctionExpression(
1368
- [b.identifier('item'), b.identifier('index')],
1369
- b.objectExpression([
1370
- b.spreadElement(
1371
- b.logicalExpression(
1372
- '??',
1373
- b.memberExpression(b.identifier(defaultName), b.identifier('index'), true),
1374
- b.objectExpression([])
1375
- )
1376
- ),
1377
- b.spreadElement(b.identifier('item')),
1378
- ])
1379
- ),
1380
- ]
1381
- );
1382
- }
1383
-
1384
- function fileAlreadyUsesSiteDataHook(ast) {
1385
- let found = false;
1386
- recast.types.visit(ast, {
1387
- visitCallExpression(pathNode) {
1388
- const callee = pathNode.node.callee;
1389
- if (callee && callee.type === 'Identifier' && callee.name === 'useSiteData') {
1390
- found = true;
1391
- return false;
1392
- }
1393
- this.traverse(pathNode);
1394
- },
1395
- });
1396
- return found;
1397
- }
1398
-
1399
- function resolveSiteDataRuntimeSpecifier(profile) {
1400
- const root = profile && profile.root;
1401
- if (!root) return '@deneb-ui/ui';
1402
- const candidates = [
1403
- ['src/lib/siteDataContext.tsx', '@/lib/siteDataContext'],
1404
- ['src/lib/siteDataContext.ts', '@/lib/siteDataContext'],
1405
- ['lib/siteDataContext.tsx', '@/lib/siteDataContext'],
1406
- ['lib/siteDataContext.ts', '@/lib/siteDataContext'],
1407
- ];
1408
- const hasAt = Object.keys(profile.aliasMap || {}).some((k) => k === '@/*' || k.startsWith('@/'));
1409
- for (const [relative, alias] of candidates) {
1410
- if (!fs.existsSync(path.join(root, relative))) continue;
1411
- if (hasAt) return alias;
1412
- const fromAbs = path.join(root, 'src/components/placeholder.tsx');
1413
- const toAbs = path.join(root, relative.replace(/\.tsx?$/, ''));
1414
- let relSpec = path.relative(path.dirname(fromAbs), toAbs).replace(/\\/g, '/');
1415
- if (!relSpec.startsWith('.')) relSpec = './' + relSpec;
1416
- return relSpec;
1417
- }
1418
- return '@deneb-ui/ui';
1419
- }
1420
-
1421
- function functionReferencesSiteData(fn) {
1422
- if (!fn || !fn.body) return false;
1423
- let found = false;
1424
- recast.types.visit(fn.body, {
1425
- visitIdentifier(pathNode) {
1426
- if (pathNode.node.name === 'siteData') {
1427
- found = true;
1428
- return false;
1429
- }
1430
- this.traverse(pathNode);
1431
- },
1432
- });
1433
- return found;
1434
- }
1435
-
1436
- function injectSiteDataHook(ast) {
1437
- let injected = false;
1438
- let anyFunctionReferencedSiteData = false;
1439
- let alreadyHasUseSiteData = false;
1440
-
1441
- recast.types.visit(ast, {
1442
- visitCallExpression(pathNode) {
1443
- if (pathNode.node.callee && pathNode.node.callee.name === 'useSiteData') {
1444
- alreadyHasUseSiteData = true;
1445
- return false;
1446
- }
1447
- this.traverse(pathNode);
1448
- },
1449
- });
1450
-
1451
- function injectIntoFunction(fn) {
1452
- if (!fn || !fn.body || fn.body.type !== 'BlockStatement') return false;
1453
- const body = fn.body.body;
1454
- const already = body.some((stmt) => recast.print(stmt).code.includes('useSiteData'));
1455
- if (already) return false;
1456
- const hook = b.variableDeclaration('const', [
1457
- b.variableDeclarator(
1458
- b.identifier('siteData'),
1459
- b.callExpression(b.identifier('useSiteData'), [])
1460
- ),
1461
- ]);
1462
- body.unshift(hook);
1463
- alreadyHasUseSiteData = true;
1464
- return true;
1465
- }
1466
-
1467
- // Pass 1: Inject useSiteData() into EVERY component function that references siteData
1468
- recast.types.visit(ast, {
1469
- visitFunctionDeclaration(pathNode) {
1470
- if (functionReferencesSiteData(pathNode.node)) {
1471
- anyFunctionReferencedSiteData = true;
1472
- if (injectIntoFunction(pathNode.node)) injected = true;
1473
- }
1474
- this.traverse(pathNode);
1475
- },
1476
- visitFunctionExpression(pathNode) {
1477
- if (functionReferencesSiteData(pathNode.node)) {
1478
- anyFunctionReferencedSiteData = true;
1479
- if (injectIntoFunction(pathNode.node)) injected = true;
1480
- }
1481
- this.traverse(pathNode);
1482
- },
1483
- visitArrowFunctionExpression(pathNode) {
1484
- if (functionReferencesSiteData(pathNode.node)) {
1485
- anyFunctionReferencedSiteData = true;
1486
- if (injectIntoFunction(pathNode.node)) injected = true;
1487
- }
1488
- this.traverse(pathNode);
1489
- },
1490
- });
1491
-
1492
- // Pass 2: If no component references siteData yet (e.g. before subsequent AST edits),
1493
- // ensure the primary component receives useSiteData()
1494
- if (!injected && !anyFunctionReferencedSiteData && !alreadyHasUseSiteData) {
1495
- recast.types.visit(ast, {
1496
- visitExportDefaultDeclaration(pathNode) {
1497
- if (injected) return false;
1498
- const decl = pathNode.node.declaration;
1499
- if (decl && (decl.type === 'FunctionDeclaration' || decl.type === 'ArrowFunctionExpression' || decl.type === 'FunctionExpression')) {
1500
- injected = injectIntoFunction(decl);
1501
- }
1502
- this.traverse(pathNode);
1503
- },
1504
- visitFunctionDeclaration(pathNode) {
1505
- if (injected) return false;
1506
- const name = pathNode.node.id && pathNode.node.id.name;
1507
- if (name && /^[A-Z]/.test(name)) {
1508
- injected = injectIntoFunction(pathNode.node);
1509
- return false;
1510
- }
1511
- this.traverse(pathNode);
1512
- },
1513
- visitVariableDeclarator(pathNode) {
1514
- if (injected) return false;
1515
- const id = pathNode.node.id;
1516
- const init = pathNode.node.init;
1517
- if (id && id.type === 'Identifier' && /^[A-Z]/.test(id.name) && init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) {
1518
- injected = injectIntoFunction(init);
1519
- return false;
1520
- }
1521
- this.traverse(pathNode);
1522
- },
1523
- });
1524
- }
1525
-
1526
- if (!injected && !anyFunctionReferencedSiteData && !alreadyHasUseSiteData) {
1527
- recast.types.visit(ast, {
1528
- visitFunctionDeclaration(pathNode) {
1529
- if (injected) return false;
1530
- injected = injectIntoFunction(pathNode.node);
1531
- return false;
1532
- },
1533
- });
1534
- }
1535
-
1536
- return injected;
1537
- }
1538
-
1539
- function resolveSiteDataSpecifier(profile, fromRelativeFile) {
1540
- const aliases = (profile && profile.aliasMap) || {};
1541
- const hasAt = Object.keys(aliases).some((k) => k === '@/*' || k.startsWith('@/'));
1542
- const hasSrc = Boolean(profile && profile.hasSrc);
1543
- const siteDataRel = hasSrc ? 'src/data/site-data.json' : 'data/site-data.json';
1544
- if (hasAt && hasSrc) return '@/data/site-data.json';
1545
-
1546
- const root = profile && profile.root ? profile.root : process.cwd();
1547
- const fromAbs = path.join(root, fromRelativeFile || 'page.tsx');
1548
- const toAbs = path.join(root, siteDataRel);
1549
- let relSpec = path.relative(path.dirname(fromAbs), toAbs).replace(/\\/g, '/');
1550
- if (!relSpec.startsWith('.')) relSpec = './' + relSpec;
1551
- return relSpec;
1552
- }
1553
-
1554
- function applyFilePlan(filePlan, profile) {
1555
- if (!filePlan.originalCode || !filePlan.transformations.length) {
1556
- return { code: filePlan.originalCode, changed: false };
1557
- }
1558
-
1559
- const ast = parseSource(filePlan.originalCode, filePlan.file);
1560
- let isClient = hasDirective(ast, 'use client') || hasClientDirective(ast) || /['"]use client['"]/.test(filePlan.originalCode.slice(0, 400));
1561
- const hasMetadata = hasMetadataExport(ast);
1562
- let applied = 0;
1563
- const failures = [];
1564
-
1565
- const supported = new Set([
1566
- 'split-action-contract',
1567
- 'form-submit-action',
1568
- 'extract-url',
1569
- 'extract-image',
1570
- 'extract-picture',
1571
- 'extract-alt',
1572
- 'extract-placeholder',
1573
- 'extract-text',
1574
- 'extract-prop',
1575
- 'wrap-text-span',
1576
- 'collection-conversion',
1577
- 'style-bind',
1578
- 'extract-tailwind-bg',
1579
- 'instrument-child-card',
1580
- 'bind-button-with-icon',
1581
- 'bind-highlighted-heading',
1582
- 'prop-flow-callsite',
1583
- 'instrument-reusable-component',
1584
- ]);
1585
-
1586
- const isTypeScript = Boolean(filePlan.file && /\.(tsx|ts)$/.test(filePlan.file));
1587
-
1588
- // Collections run first: they rewrite the array declaration and add an index
1589
- // parameter, and later per-element edits must observe that shape.
1590
- const ordered = [...filePlan.transformations].sort(
1591
- (left, right) =>
1592
- Number(right.operation === 'collection-conversion') -
1593
- Number(left.operation === 'collection-conversion')
1594
- );
1595
-
1596
- for (const transform of ordered) {
1597
- if (transform.decision === 'skip') continue;
1598
- if (!supported.has(transform.operation)) continue;
1599
-
1600
- if (transform.operation === 'instrument-child-card') {
1601
- try {
1602
- if (instrumentChildCardComponent(ast, transform, isTypeScript)) applied++;
1603
- } catch (err) {
1604
- failures.push({ loc: transform.loc, reason: err.message });
1605
- }
1606
- continue;
1607
- }
1608
-
1609
- if (transform.operation === 'instrument-reusable-component') {
1610
- try {
1611
- if (instrumentReusableComponent(ast, transform, isTypeScript)) applied++;
1612
- } catch (err) {
1613
- failures.push({ loc: transform.loc, reason: err.message });
1614
- }
1615
- continue;
1616
- }
1617
-
1618
- if (transform.operation === 'collection-conversion') {
1619
- try {
1620
- if (applyCollectionTransform(ast, transform, isClient, isTypeScript)) applied++;
1621
- else failures.push({ loc: transform.loc, reason: 'collection-not-bindable' });
1622
- } catch (err) {
1623
- failures.push({ loc: transform.loc, reason: err.message });
1624
- }
1625
- continue;
1626
- }
1627
-
1628
- const pathNode = findElementByLoc(ast, transform.loc, transform.componentName || transform.tag);
1629
- if (!pathNode) {
1630
- failures.push({ loc: transform.loc, reason: 'node-not-found' });
1631
- continue;
1632
- }
1633
- try {
1634
- applyTransformToElement(pathNode, transform);
1635
- applied++;
1636
- } catch (err) {
1637
- failures.push({ loc: transform.loc, reason: err.message });
1638
- }
1639
- }
1640
-
1641
- if (applied === 0) {
1642
- return { code: filePlan.originalCode, changed: false, applied, failures };
1643
- }
1644
-
1645
- if (!isClient && profile?.router === 'next-app' && !hasMetadata && canSafelyInjectClientDirective(ast, filePlan.file, profile)) {
1646
- if (needsClientDirective(ast, filePlan.originalCode)) {
1647
- ensureClientDirective(ast);
1648
- isClient = true;
1649
- }
1650
- }
1651
-
1652
- const siteDataImport = resolveSiteDataSpecifier(profile, filePlan.file);
1653
- if (isClient) {
1654
- const runtimeSpecifier = resolveSiteDataRuntimeSpecifier(profile);
1655
- injectSiteDataHook(ast);
1656
- ensureImport(ast, runtimeSpecifier, ['useSiteData']);
1657
- } else {
1658
- ensureDefaultImport(ast, siteDataImport, 'siteData');
1659
- }
1660
- sanitizeDuplicateBindings(ast);
1661
-
1662
- // Sanitize any conflicting data-preview-static on elements with editable markers
1663
- sanitizeContradictoryMarkers(ast);
1664
- healBroadContainerMarkers(ast);
1665
- healEmptyStateConditionals(ast);
1666
- healHiddenPreviewMarkers(ast);
1667
- healLegacyProductDetailLinks(ast);
1668
- healSectionOverflowHidden(ast);
1669
- healDecorativeOverlays(ast);
1670
-
1671
- // Page keys are stamped in a separate route-driven pass so App Router and
1672
- // Pages Router projects are handled by the same logic.
1673
- const code = printSource(ast, filePlan.originalCode);
1674
- return { code, changed: true, applied, failures, usedClientHook: isClient };
1675
- }
1676
-
1677
- /**
1678
- * Stamps a route's page component with data-preview-page-key. Fivora requires
1679
- * exactly one per exported route, so the key comes from the scanned route id
1680
- * rather than being guessed from the filename — Pages Router uses
1681
- * `pages/contact.jsx`, App Router uses `app/contact/page.tsx`.
1682
- *
1683
- * `<main>` is preferred, but any project shape is supported by falling back to
1684
- * the outermost element the page component returns.
1685
- */
1686
- function instrumentPageKey(code, relativeFile, pageKey) {
1687
- if (code.includes('data-preview-page-key')) return { code, updated: false };
1688
- const key = pageKey || inferPageKey(relativeFile);
1689
-
1690
- let ast;
1691
- try {
1692
- ast = parseSource(code, relativeFile);
1693
- } catch {
1694
- return { code, updated: false };
1695
- }
1696
-
1697
- const attribute = () =>
1698
- b.jsxAttribute(b.jsxIdentifier('data-preview-page-key'), b.stringLiteral(key));
1699
-
1700
- let target = null;
1701
- recast.types.visit(ast, {
1702
- visitJSXOpeningElement(pathNode) {
1703
- const name = pathNode.node.name && pathNode.node.name.name;
1704
- if (!target && name === 'main') {
1705
- target = pathNode.node;
1706
- return false;
1707
- }
1708
- this.traverse(pathNode);
1709
- },
1710
- });
1711
-
1712
- if (!target) {
1713
- // No <main>: use the first host element of the returned tree so the marker
1714
- // still lands inside this route's exported HTML.
1715
- recast.types.visit(ast, {
1716
- visitJSXElement(pathNode) {
1717
- if (target) return false;
1718
- const name = getJsxName(pathNode.node);
1719
- if (typeof name === 'string' && /^[a-z]/.test(name)) {
1720
- target = pathNode.node.openingElement;
1721
- return false;
1722
- }
1723
- this.traverse(pathNode);
1724
- },
1725
- });
1726
- }
1727
-
1728
- if (!target) return { code, updated: false };
1729
- target.attributes = target.attributes || [];
1730
- target.attributes.unshift(attribute());
1731
- return { code: printSource(ast, code), updated: true };
1732
- }
1733
-
1734
- function inferPageKey(relativeFile) {
1735
- const posix = toPosix(relativeFile);
1736
- if (/(^|\/)page\.(tsx|jsx|js)$/.test(posix)) {
1737
- const parts = posix.split('/');
1738
- const idx = parts.lastIndexOf('app');
1739
- const segs = parts.slice(idx + 1, -1).filter((s) => !(s.startsWith('(') && s.endsWith(')')));
1740
- if (!segs.length) return 'home';
1741
- return segs.join('_').replace(/[^a-z0-9_-]/gi, '_').toLowerCase();
1742
- }
1743
- return 'home';
1744
- }
1745
-
1746
- function ensureHtmlBodyHydration(ast) {
1747
- recast.types.visit(ast, {
1748
- visitJSXOpeningElement(pathNode) {
1749
- const name = pathNode.node.name;
1750
- const tag = name && name.type === 'JSXIdentifier' ? name.name : '';
1751
- if (tag === 'html' || tag === 'body') {
1752
- const has = (pathNode.node.attributes || []).some(
1753
- (attr) => attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'suppressHydrationWarning',
1754
- );
1755
- if (!has) {
1756
- pathNode.node.attributes.push(b.jsxAttribute(b.jsxIdentifier('suppressHydrationWarning')));
1757
- }
1758
- }
1759
- this.traverse(pathNode);
1760
- },
1761
- });
1762
- }
1763
-
1764
- function findSiteDataJsonLocalName(ast) {
1765
- const program = ast.program || ast;
1766
- for (const node of program.body || []) {
1767
- if (node.type !== 'ImportDeclaration') continue;
1768
- const src = node.source && node.source.value;
1769
- if (typeof src !== 'string' || !/site-data\.json/.test(src)) continue;
1770
- const spec = (node.specifiers || []).find((s) => s.type === 'ImportDefaultSpecifier');
1771
- if (spec && spec.local) return spec.local.name;
1772
- }
1773
- return null;
1774
- }
1775
-
1776
- function ensureProviderInitialData(ast, ident) {
1777
- let added = false;
1778
- recast.types.visit(ast, {
1779
- visitJSXOpeningElement(pathNode) {
1780
- const name = pathNode.node.name;
1781
- const tag = name && name.type === 'JSXIdentifier' ? name.name : '';
1782
- if (tag === 'SiteDataProvider' || tag === 'DenebDataProvider' || tag === 'DenebSiteDataProvider') {
1783
- const has = hasJsxAttribute(pathNode.node, 'initialSiteData');
1784
- if (!has) {
1785
- pathNode.node.attributes = pathNode.node.attributes || [];
1786
- pathNode.node.attributes.push(
1787
- b.jsxAttribute(
1788
- b.jsxIdentifier('initialSiteData'),
1789
- b.jsxExpressionContainer(b.identifier(ident))
1790
- )
1791
- );
1792
- added = true;
1793
- }
1794
- }
1795
- this.traverse(pathNode);
1796
- },
1797
- });
1798
- return added;
1799
- }
1800
-
1801
- const CANONICAL_SITE_DATA_CONTEXT = `'use client';
1802
-
1803
- export {
1804
- SiteDataProvider,
1805
- useSiteData,
1806
- contentText,
1807
- contentObject,
1808
- contentList,
1809
- PREVIEW_DATA_MESSAGE,
1810
- LEGACY_PREVIEW_DATA_MESSAGE,
1811
- PREVIEW_READY_MESSAGE,
1812
- LEGACY_PREVIEW_READY_MESSAGE,
1813
- PREVIEW_FOCUS_MESSAGE,
1814
- LEGACY_PREVIEW_FOCUS_MESSAGE,
1815
- PREVIEW_FIELD_ATTRIBUTE,
1816
- } from '@deneb-ui/ui';
1817
-
1818
- export type { SiteData, SiteDataProviderProps } from '@deneb-ui/ui';
1819
- `;
1820
-
1821
- function rewriteRecursiveSiteDataContext(code) {
1822
- const recursive =
1823
- /export\s+function\s+SiteDataProvider\b/.test(code) &&
1824
- /<(?:Base)?SiteDataProvider\b/.test(code);
1825
- if (!recursive) return { code, updated: false };
1826
- return { code: CANONICAL_SITE_DATA_CONTEXT, updated: true };
1827
- }
1828
-
1829
- function instrumentLayoutSource(code, siteDataImport, providerImport = '@deneb-ui/ui') {
1830
- const ast = parseSource(code, 'layout.tsx');
1831
- ensureHtmlBodyHydration(ast);
1832
-
1833
- let jsonIdent = findSiteDataJsonLocalName(ast);
1834
- if (!jsonIdent) {
1835
- jsonIdent = 'initialSiteData';
1836
- ensureDefaultImport(ast, siteDataImport, jsonIdent);
1837
- }
1838
-
1839
- const hasProvider = /SiteDataProvider|DenebDataProvider|<Providers\b/.test(code);
1840
- let wrapped = hasProvider;
1841
-
1842
- if (!hasProvider) {
1843
- ensureImport(ast, providerImport, ['SiteDataProvider']);
1844
- recast.types.visit(ast, {
1845
- visitJSXExpressionContainer(pathNode) {
1846
- if (wrapped) return false;
1847
- const expr = pathNode.node.expression;
1848
- if (expr && expr.type === 'Identifier' && expr.name === 'children') {
1849
- pathNode.replace(
1850
- b.jsxElement(
1851
- b.jsxOpeningElement(
1852
- b.jsxIdentifier('SiteDataProvider'),
1853
- [
1854
- b.jsxAttribute(
1855
- b.jsxIdentifier('initialSiteData'),
1856
- b.jsxExpressionContainer(b.identifier(jsonIdent))
1857
- ),
1858
- ],
1859
- false
1860
- ),
1861
- b.jsxClosingElement(b.jsxIdentifier('SiteDataProvider')),
1862
- [pathNode.node],
1863
- false
1864
- )
1865
- );
1866
- wrapped = true;
1867
- return false;
1868
- }
1869
- this.traverse(pathNode);
1870
- },
1871
- });
1872
-
1873
- if (!wrapped) {
1874
- recast.types.visit(ast, {
1875
- visitJSXElement(pathNode) {
1876
- if (wrapped) return false;
1877
- const name = getJsxName(pathNode.node);
1878
- if (name === 'Component') {
1879
- pathNode.replace(
1880
- b.jsxElement(
1881
- b.jsxOpeningElement(
1882
- b.jsxIdentifier('SiteDataProvider'),
1883
- [
1884
- b.jsxAttribute(
1885
- b.jsxIdentifier('initialSiteData'),
1886
- b.jsxExpressionContainer(b.identifier(jsonIdent))
1887
- ),
1888
- ],
1889
- false
1890
- ),
1891
- b.jsxClosingElement(b.jsxIdentifier('SiteDataProvider')),
1892
- [pathNode.node],
1893
- false
1894
- )
1895
- );
1896
- wrapped = true;
1897
- return false;
1898
- }
1899
- this.traverse(pathNode);
1900
- },
1901
- });
1902
- }
1903
- }
1904
-
1905
- ensureProviderInitialData(ast, jsonIdent);
1906
- injectPlatformAdditionalPages(ast, providerImport);
1907
- injectDualModeTheme(ast, jsonIdent, providerImport);
1908
- sanitizeDuplicateBindings(ast);
1909
- const next = printSource(ast, code);
1910
- return { code: next, updated: next !== code };
1911
- }
1912
-
1913
- /**
1914
- * Injects <ThemeStyles /> and <ThemeToggle /> into the layout JSX tree.
1915
- * ThemeStyles applies light/dark variables and auto-contrast rules.
1916
- * ThemeToggle provides an out-of-the-box floating theme switcher.
1917
- */
1918
- function injectDualModeTheme(ast, jsonIdent, providerImport = '@deneb-ui/ui') {
1919
- if (!ast) return;
1920
- let hasThemeStyles = false;
1921
- let hasThemeToggle = false;
1922
- recast.types.visit(ast, {
1923
- visitJSXIdentifier(pathNode) {
1924
- if (pathNode.node.name === 'ThemeStyles') hasThemeStyles = true;
1925
- if (pathNode.node.name === 'ThemeToggle') hasThemeToggle = true;
1926
- this.traverse(pathNode);
1927
- },
1928
- });
1929
-
1930
- const importsToAdd = [];
1931
- if (!hasThemeStyles) importsToAdd.push('ThemeStyles');
1932
- if (!hasThemeToggle) importsToAdd.push('ThemeToggle');
1933
- if (importsToAdd.length === 0) return;
1934
-
1935
- if (!hasThemeStyles) {
1936
- let stylesInjected = false;
1937
- const snippet = parseSource(
1938
- `<ThemeStyles theme={${jsonIdent}?.template?.structure?.theme || ${jsonIdent}?.theme} enableDualMode />`,
1939
- 'snippet.tsx'
1940
- );
1941
- const stylesEl = snippet.program.body[0].expression;
1942
-
1943
- // Try <head> first
1944
- recast.types.visit(ast, {
1945
- visitJSXElement(pathNode) {
1946
- if (stylesInjected) return false;
1947
- const name = getJsxName(pathNode.node);
1948
- if (name === 'head' || name === 'Head') {
1949
- pathNode.node.children = [b.jsxText('\n '), stylesEl, ...(pathNode.node.children || [])];
1950
- stylesInjected = true;
1951
- return false;
1952
- }
1953
- this.traverse(pathNode);
1954
- },
1955
- });
1956
-
1957
- // If no <head>, inject inside SiteDataProvider or <body>
1958
- if (!stylesInjected) {
1959
- recast.types.visit(ast, {
1960
- visitJSXElement(pathNode) {
1961
- if (stylesInjected) return false;
1962
- const name = getJsxName(pathNode.node);
1963
- if (name === 'SiteDataProvider' || name === 'body') {
1964
- pathNode.node.children = [b.jsxText('\n '), stylesEl, ...(pathNode.node.children || [])];
1965
- stylesInjected = true;
1966
- return false;
1967
- }
1968
- this.traverse(pathNode);
1969
- },
1970
- });
1971
- }
1972
- }
1973
-
1974
- if (!hasThemeToggle) {
1975
- let toggleInjected = false;
1976
- const snippet = parseSource(
1977
- '<ThemeToggle showLabel className="fixed bottom-6 left-6 z-40" />',
1978
- 'snippet.tsx'
1979
- );
1980
- const toggleEl = snippet.program.body[0].expression;
1981
-
1982
- recast.types.visit(ast, {
1983
- visitJSXElement(pathNode) {
1984
- if (toggleInjected) return false;
1985
- const name = getJsxName(pathNode.node);
1986
- if (name === 'SiteDataProvider' || name === 'body') {
1987
- pathNode.node.children = [...(pathNode.node.children || []), b.jsxText('\n '), toggleEl];
1988
- toggleInjected = true;
1989
- return false;
1990
- }
1991
- this.traverse(pathNode);
1992
- },
1993
- });
1994
- }
1995
-
1996
- ensureImport(ast, providerImport || '@deneb-ui/ui', importsToAdd);
1997
- }
1998
-
1999
- /**
2000
- * Injects <PlatformAdditionalPages /> into the layout JSX tree after <main>.
2001
- * This component handles all additionalPages visual-editing markers correctly
2002
- * so template developers never need to know the complex nesting rules.
2003
- *
2004
- * The injected pattern looks like:
2005
- * <main>{children}</main>
2006
- * <PlatformAdditionalPages /> ← injected (inside the SiteDataProvider/body)
2007
- *
2008
- * If PlatformAdditionalPages is already in the source, it's a no-op.
2009
- */
2010
- function injectPlatformAdditionalPages(ast, providerImport) {
2011
- if (!ast) return;
2012
- // Already injected - skip
2013
- let alreadyPresent = false;
2014
- recast.types.visit(ast, {
2015
- visitJSXIdentifier(pathNode) {
2016
- if (pathNode.node.name === 'PlatformAdditionalPages') {
2017
- alreadyPresent = true;
2018
- return false;
2019
- }
2020
- this.traverse(pathNode);
2021
- },
2022
- });
2023
- if (alreadyPresent) return;
2024
-
2025
- // Inject after the first <main> element in any JSX tree
2026
- let injected = false;
2027
- recast.types.visit(ast, {
2028
- visitJSXElement(pathNode) {
2029
- if (injected) return false;
2030
- const name = getJsxName(pathNode.node);
2031
- if (name === 'main') {
2032
- const parent = pathNode.parent;
2033
- if (!parent || !Array.isArray(parent.node.children)) {
2034
- this.traverse(pathNode);
2035
- return;
2036
- }
2037
- const siblings = parent.node.children;
2038
- const idx = siblings.indexOf(pathNode.node);
2039
- if (idx === -1) {
2040
- this.traverse(pathNode);
2041
- return;
2042
- }
2043
- // Build <PlatformAdditionalPages />
2044
- const platformElement = b.jsxElement(
2045
- b.jsxOpeningElement(
2046
- b.jsxIdentifier('PlatformAdditionalPages'),
2047
- [],
2048
- true // self-closing
2049
- ),
2050
- null,
2051
- [],
2052
- true
2053
- );
2054
- const newline = b.jsxText('\n ');
2055
- siblings.splice(idx + 1, 0, newline, platformElement);
2056
- ensureImport(ast, providerImport || '@deneb-ui/ui', ['PlatformAdditionalPages']);
2057
- injected = true;
2058
- return false;
2059
- }
2060
- this.traverse(pathNode);
2061
- },
2062
- });
2063
- }
2064
-
2065
- function ensureJsonModule(tsconfig) {
2066
- if (!tsconfig || !tsconfig.compilerOptions) return { config: tsconfig, changed: false };
2067
- if (tsconfig.compilerOptions.resolveJsonModule) return { config: tsconfig, changed: false };
2068
- return {
2069
- config: {
2070
- ...tsconfig,
2071
- compilerOptions: {
2072
- ...tsconfig.compilerOptions,
2073
- resolveJsonModule: true,
2074
- },
2075
- },
2076
- changed: true,
2077
- };
2078
- }
2079
-
2080
- function sanitizeContradictoryMarkers(ast) {
2081
- let cleaned = 0;
2082
- recast.types.visit(ast, {
2083
- visitJSXElement(pathNode) {
2084
- const opening = pathNode.node.openingElement;
2085
- if (!opening || !Array.isArray(opening.attributes)) {
2086
- this.traverse(pathNode);
2087
- return;
2088
- }
2089
- const attrs = opening.attributes;
2090
- const hasStatic = attrs.some(
2091
- (a) => a.type === 'JSXAttribute' && a.name && a.name.name === 'data-preview-static'
2092
- );
2093
- if (!hasStatic) {
2094
- this.traverse(pathNode);
2095
- return;
2096
- }
2097
-
2098
- // 0. Broad container collision: broad content containers cannot carry data-preview-static
2099
- const tag = getJsxName(pathNode.node);
2100
- const lower = String(tag || '').toLowerCase();
2101
- if (BROAD_CONTENT_CONTAINERS.has(tag) || BROAD_CONTENT_CONTAINERS.has(lower)) {
2102
- opening.attributes = attrs.filter(
2103
- (a) => !(a.type === 'JSXAttribute' && a.name && a.name.name === 'data-preview-static')
2104
- );
2105
- cleaned++;
2106
- this.traverse(pathNode);
2107
- return;
2108
- }
2109
-
2110
- // 1. Direct collision: cannot share element with editable markers
2111
- const hasDirectEditable = attrs.some(
2112
- (a) => a.type === 'JSXAttribute' && a.name && (
2113
- a.name.name === 'data-preview-field-path' ||
2114
- a.name.name === 'data-preview-list-path' ||
2115
- a.name.name === 'data-preview-item-path'
2116
- )
2117
- );
2118
-
2119
- // 2. Hierarchical collision: static element cannot enclose editable descendants
2120
- let hasNestedEditable = false;
2121
- if (!hasDirectEditable) {
2122
- recast.types.visit(pathNode.node, {
2123
- visitJSXElement(inner) {
2124
- if (inner.node === pathNode.node) {
2125
- this.traverse(inner);
2126
- return;
2127
- }
2128
- const innerAttrs = inner.node.openingElement?.attributes || [];
2129
- if (innerAttrs.some(
2130
- (a) => a.type === 'JSXAttribute' && a.name && (
2131
- a.name.name === 'data-preview-field-path' ||
2132
- a.name.name === 'data-preview-list-path' ||
2133
- a.name.name === 'data-preview-item-path'
2134
- )
2135
- )) {
2136
- hasNestedEditable = true;
2137
- return false;
2138
- }
2139
- this.traverse(inner);
2140
- },
2141
- });
2142
- }
2143
-
2144
- if (hasDirectEditable || hasNestedEditable) {
2145
- opening.attributes = attrs.filter(
2146
- (a) => !(a.type === 'JSXAttribute' && a.name && a.name.name === 'data-preview-static')
2147
- );
2148
- cleaned++;
2149
- }
2150
- this.traverse(pathNode);
2151
- },
2152
- });
2153
- return cleaned;
2154
- }
2155
-
2156
- function sanitizeContradictoryMarkersInSource(code, relativeFile) {
2157
- if (!code.includes('data-preview-static') && !code.includes('data-preview-field-path') && !code.includes('data-preview-list-path') && !code.includes('data-preview-item-path') && !code.includes('overflow-hidden') && !code.includes('hidden')) {
2158
- return { code, updated: false };
2159
- }
2160
- let ast;
2161
- try {
2162
- ast = parseSource(code, relativeFile);
2163
- } catch {
2164
- return { code, updated: false };
2165
- }
2166
- const cleaned = sanitizeContradictoryMarkers(ast);
2167
- const healedBroad = healBroadContainerMarkers(ast);
2168
- const healedEmpty = healEmptyStateConditionals(ast);
2169
- const healedHidden = healHiddenPreviewMarkers(ast);
2170
- const healedOverflow = healSectionOverflowHidden(ast);
2171
- const healedOverlay = healDecorativeOverlays(ast);
2172
- if (cleaned === 0 && healedBroad === 0 && healedEmpty === 0 && healedHidden === 0 && healedOverflow === 0 && healedOverlay === 0) return { code, updated: false };
2173
- return { code: printSource(ast, code), updated: true, count: cleaned + healedBroad + healedEmpty + healedHidden + healedOverflow + healedOverlay };
2174
- }
2175
-
2176
- function healBroadContainerMarkers(ast) {
2177
- let healed = 0;
2178
- recast.types.visit(ast, {
2179
- visitJSXElement(pathNode) {
2180
- const node = pathNode.node;
2181
- const tag = getJsxName(node);
2182
- const lower = String(tag || '').toLowerCase();
2183
- if (!BROAD_CONTENT_CONTAINERS.has(tag) && !BROAD_CONTENT_CONTAINERS.has(lower)) {
2184
- this.traverse(pathNode);
2185
- return;
2186
- }
2187
- const fieldAttr = findJsxAttribute(node, 'data-preview-field-path');
2188
- if (!fieldAttr) {
2189
- this.traverse(pathNode);
2190
- return;
2191
- }
2192
-
2193
- const fieldValue = fieldAttr.value;
2194
- node.openingElement.attributes = (node.openingElement.attributes || []).filter(
2195
- (attr) =>
2196
- !(
2197
- attr.type === 'JSXAttribute' &&
2198
- attr.name &&
2199
- (attr.name.name === 'data-preview-field-path' ||
2200
- attr.name.name === 'data-preview-style-target' ||
2201
- attr.name.name === 'data-preview-style-type')
2202
- )
2203
- );
2204
-
2205
- const leaf = findFirstLeafChild(node);
2206
- if (leaf && !hasJsxAttribute(leaf, 'data-preview-field-path')) {
2207
- leaf.openingElement.attributes.push(
2208
- b.jsxAttribute(b.jsxIdentifier('data-preview-field-path'), fieldValue)
2209
- );
2210
- healed++;
2211
- } else {
2212
- const span = b.jsxElement(
2213
- b.jsxOpeningElement(
2214
- b.jsxIdentifier('span'),
2215
- [b.jsxAttribute(b.jsxIdentifier('data-preview-field-path'), fieldValue)],
2216
- false
2217
- ),
2218
- b.jsxClosingElement(b.jsxIdentifier('span')),
2219
- node.children || [],
2220
- false
2221
- );
2222
- node.children = [span];
2223
- healed++;
2224
- }
2225
- this.traverse(pathNode);
2226
- },
2227
- });
2228
- return healed;
2229
- }
2230
-
2231
- function findFirstLeafChild(node) {
2232
- const leafTags = new Set(['span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'label', 'img', 'button', 'a', 'strong', 'em', 'small']);
2233
- for (const child of node.children || []) {
2234
- if (child && child.type === 'JSXElement') {
2235
- const name = getJsxName(child);
2236
- if (leafTags.has(name) || leafTags.has(String(name).toLowerCase())) return child;
2237
- const nested = findFirstLeafChild(child);
2238
- if (nested) return nested;
2239
- }
2240
- }
2241
- return null;
2242
- }
2243
-
2244
- function isUnsafeToStripCondition(leftExpr, rightExpr, pathNode) {
2245
- if (!leftExpr || !rightExpr) return true;
2246
-
2247
- // 1. Collect all identifiers used on the left-hand side
2248
- const leftIdentifiers = new Set();
2249
- recast.types.visit(leftExpr, {
2250
- visitIdentifier(idPath) {
2251
- leftIdentifiers.add(idPath.node.name);
2252
- return false;
2253
- },
2254
- });
2255
-
2256
- if (leftIdentifiers.size === 0) return true;
2257
-
2258
- // 2. State & modal variable keywords (e.g., selectedDish, lightboxIndex, isOpen, activeTab)
2259
- const stateKeywordRegex = /(?:open|active|selected|current|expanded|visible|show|modal|dialog|lightbox|drawer|loading|tab|filter|step|index)/i;
2260
- for (const id of leftIdentifiers) {
2261
- if (stateKeywordRegex.test(id)) {
2262
- return true; // NEVER strip modal/state/selection guards
2263
- }
2264
- }
2265
-
2266
- // 3. Null / undefined / equality / comparison checks
2267
- let hasComparisonOrNullCheck = false;
2268
- recast.types.visit(leftExpr, {
2269
- visitBinaryExpression(bPath) {
2270
- const op = bPath.node.operator;
2271
- if (['!==', '!=', '===', '==', '>', '<', '>=', '<='].includes(op)) {
2272
- hasComparisonOrNullCheck = true;
2273
- }
2274
- this.traverse(bPath);
2275
- },
2276
- visitUnaryExpression(uPath) {
2277
- if (uPath.node.operator === '!') {
2278
- hasComparisonOrNullCheck = true;
2279
- }
2280
- this.traverse(uPath);
2281
- },
2282
- });
2283
- if (hasComparisonOrNullCheck) return true;
2284
-
2285
- // 4. Check if ANY identifier in leftExpr is referenced inside rightExpr
2286
- let rightReferencesLeftId = false;
2287
- recast.types.visit(rightExpr, {
2288
- visitIdentifier(idPath) {
2289
- if (leftIdentifiers.has(idPath.node.name)) {
2290
- rightReferencesLeftId = true;
2291
- return false;
2292
- }
2293
- this.traverse(idPath);
2294
- },
2295
- });
2296
- if (rightReferencesLeftId) return true;
2297
-
2298
- // 5. Check if rightExpr or any of its ancestors is an interactive modal / dialog / overlay / AnimatePresence
2299
- let curr = pathNode;
2300
- while (curr && curr.parent) {
2301
- const parentNode = curr.parent.node;
2302
- if (parentNode && parentNode.type === 'JSXElement') {
2303
- const parentTag = getJsxName(parentNode);
2304
- if (/^(?:AnimatePresence|Dialog|Modal|Lightbox|Drawer|Popup|Sheet|Portal)$/i.test(parentTag)) {
2305
- return true;
2306
- }
2307
- }
2308
- curr = curr.parent;
2309
- }
2310
-
2311
- let isModalOrOverlay = false;
2312
- recast.types.visit(rightExpr, {
2313
- visitJSXElement(elPath) {
2314
- const node = elPath.node;
2315
- const tag = getJsxName(node);
2316
- if (/^(?:AnimatePresence|Dialog|Modal|Lightbox|Drawer|Popup|Sheet|Portal)$/i.test(tag)) {
2317
- isModalOrOverlay = true;
2318
- return false;
2319
- }
2320
- const classAttr = findJsxAttribute(node, 'className') || findJsxAttribute(node, 'class');
2321
- if (classAttr && classAttr.value) {
2322
- const val = classAttr.value.value || '';
2323
- if (typeof val === 'string' && (/fixed\s+inset-0/i.test(val) || /z-(?:50|\[50\]|40)/i.test(val) || /backdrop-blur/i.test(val))) {
2324
- isModalOrOverlay = true;
2325
- return false;
2326
- }
2327
- }
2328
- const roleAttr = findJsxAttribute(node, 'role');
2329
- if (roleAttr && roleAttr.value && /^(?:dialog|alertdialog)$/i.test(String(roleAttr.value.value || ''))) {
2330
- isModalOrOverlay = true;
2331
- return false;
2332
- }
2333
- this.traverse(elPath);
2334
- },
2335
- });
2336
- if (isModalOrOverlay) return true;
2337
-
2338
- // 6. Only allow stripping if leftExpr is purely a direct siteData content member chain
2339
- const leftCode = recast.print(leftExpr).code;
2340
- const isDirectContentCheck = /siteData(?:\?\.|\.)content(?:\?\.|\.)/i.test(leftCode);
2341
- if (!isDirectContentCheck) return true;
2342
-
2343
- return false;
2344
- }
2345
-
2346
- function healEmptyStateConditionals(ast) {
2347
- let healed = 0;
2348
-
2349
- function jsxHasPreviewMarker(node) {
2350
- if (!node) return false;
2351
- if (node.type === 'JSXElement') {
2352
- if (
2353
- hasJsxAttribute(node, 'data-preview-field-path') ||
2354
- hasJsxAttribute(node, 'data-preview-list-path') ||
2355
- hasJsxAttribute(node, 'data-preview-item-path')
2356
- ) {
2357
- return true;
2358
- }
2359
- return (node.children || []).some((child) => jsxHasPreviewMarker(child));
2360
- }
2361
- if (node.type === 'JSXFragment') {
2362
- return (node.children || []).some((child) => jsxHasPreviewMarker(child));
2363
- }
2364
- if (node.type === 'ParenthesizedExpression') return jsxHasPreviewMarker(node.expression);
2365
- return false;
2366
- }
2367
-
2368
- recast.types.visit(ast, {
2369
- visitLogicalExpression(pathNode) {
2370
- const expr = pathNode.node;
2371
- if (expr.operator === '&&' && jsxHasPreviewMarker(expr.right)) {
2372
- if (!isUnsafeToStripCondition(expr.left, expr.right, pathNode)) {
2373
- pathNode.replace(expr.right);
2374
- healed++;
2375
- return false;
2376
- }
2377
- }
2378
- this.traverse(pathNode);
2379
- },
2380
- visitJSXExpressionContainer(pathNode) {
2381
- const expr = pathNode.node.expression;
2382
- if (!expr) {
2383
- this.traverse(pathNode);
2384
- return;
2385
- }
2386
- if (expr.type === 'LogicalExpression' && expr.operator === '&&' && jsxHasPreviewMarker(expr.right)) {
2387
- if (!isUnsafeToStripCondition(expr.left, expr.right, pathNode)) {
2388
- pathNode.node.expression = expr.right;
2389
- healed++;
2390
- return false;
2391
- }
2392
- }
2393
- if (
2394
- expr.type === 'ConditionalExpression' &&
2395
- jsxHasPreviewMarker(expr.consequent) &&
2396
- (isNullish(expr.alternate) || isNullish(expr.consequent))
2397
- ) {
2398
- if (!isUnsafeToStripCondition(expr.test, expr.consequent, pathNode)) {
2399
- pathNode.node.expression = jsxHasPreviewMarker(expr.consequent) && !isNullish(expr.consequent)
2400
- ? expr.consequent
2401
- : expr.alternate;
2402
- healed++;
2403
- return false;
2404
- }
2405
- }
2406
- this.traverse(pathNode);
2407
- },
2408
- });
2409
- return healed;
2410
- }
2411
-
2412
- function isNullish(node) {
2413
- if (!node) return true;
2414
- if (node.type === 'NullLiteral') return true;
2415
- if (node.type === 'Literal' && (node.value === null || node.value === false || node.value === undefined)) return true;
2416
- if (node.type === 'BooleanLiteral' && node.value === false) return true;
2417
- if (node.type === 'Identifier' && (node.name === 'undefined' || node.name === 'null')) return true;
2418
- return false;
2419
- }
2420
-
2421
- function healHiddenPreviewMarkers(ast) {
2422
- let healed = 0;
2423
- recast.types.visit(ast, {
2424
- visitJSXOpeningElement(pathNode) {
2425
- const attrs = pathNode.node.attributes || [];
2426
- const hasEditable = attrs.some(
2427
- (attr) =>
2428
- attr.type === 'JSXAttribute' &&
2429
- attr.name &&
2430
- (attr.name.name === 'data-preview-field-path' ||
2431
- attr.name.name === 'data-preview-list-path' ||
2432
- attr.name.name === 'data-preview-item-path')
2433
- );
2434
- if (!hasEditable) {
2435
- this.traverse(pathNode);
2436
- return;
2437
- }
2438
- const classAttr = attrs.find((attr) => attr.type === 'JSXAttribute' && attr.name && (attr.name.name === 'className' || attr.name.name === 'class'));
2439
- let classText = '';
2440
- if (classAttr && classAttr.value) {
2441
- if (classAttr.value.type === 'StringLiteral' || classAttr.value.type === 'Literal') classText = String(classAttr.value.value || '');
2442
- else if (classAttr.value.type === 'JSXExpressionContainer' && classAttr.value.expression) {
2443
- const expr = classAttr.value.expression;
2444
- if (expr.type === 'StringLiteral' || expr.type === 'Literal') classText = String(expr.value || '');
2445
- if (expr.type === 'TemplateLiteral') classText = (expr.quasis || []).map((q) => q.value.cooked || q.value.raw || '').join(' ');
2446
- }
2447
- }
2448
- const isHidden =
2449
- attrs.some((attr) => attr.type === 'JSXAttribute' && attr.name && (
2450
- attr.name.name === 'hidden' ||
2451
- (attr.name.name === 'aria-hidden' && /true/.test(recast.print(attr).code))
2452
- )) ||
2453
- /(?<![\w-])hidden(?![a-zA-Z0-9_-])/.test(classText) ||
2454
- attrs.some((attr) => attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'style' && /(?:display\s*:\s*['"]none['"]|visibility\s*:\s*['"]hidden['"])/.test(recast.print(attr).code));
2455
-
2456
- if (isHidden) {
2457
- pathNode.node.attributes = attrs.filter(
2458
- (attr) =>
2459
- !(
2460
- attr.type === 'JSXAttribute' &&
2461
- attr.name &&
2462
- (attr.name.name === 'data-preview-field-path' ||
2463
- attr.name.name === 'data-preview-list-path' ||
2464
- attr.name.name === 'data-preview-item-path' ||
2465
- attr.name.name === 'data-preview-style-target' ||
2466
- attr.name.name === 'data-preview-style-type')
2467
- )
2468
- );
2469
- healed++;
2470
- }
2471
- this.traverse(pathNode);
2472
- },
2473
- });
2474
- return healed;
2475
- }
2476
-
2477
- function healLegacyProductDetailLinks(ast) {
2478
- let healed = 0;
2479
- let needsImport = false;
2480
-
2481
- recast.types.visit(ast, {
2482
- visitJSXAttribute(pathNode) {
2483
- const attr = pathNode.node;
2484
- if (attr.name && attr.name.name === 'href' && attr.value) {
2485
- if (attr.value.type === 'JSXExpressionContainer' && attr.value.expression) {
2486
- const expr = attr.value.expression;
2487
- if (expr.type === 'TemplateLiteral') {
2488
- const quasis = expr.quasis || [];
2489
- if (quasis.length >= 1 && typeof quasis[0].value.raw === 'string' && quasis[0].value.raw.startsWith('/products/')) {
2490
- const arg = expr.expressions && expr.expressions[0];
2491
- if (arg) {
2492
- attr.value.expression = b.callExpression(
2493
- b.identifier('platformProductDetailHref'),
2494
- [arg]
2495
- );
2496
- healed++;
2497
- needsImport = true;
2498
- }
2499
- }
2500
- } else if (
2501
- expr.type === 'BinaryExpression' &&
2502
- expr.operator === '+' &&
2503
- expr.left &&
2504
- (expr.left.value === '/products/' || expr.left.value === '/products')
2505
- ) {
2506
- attr.value.expression = b.callExpression(
2507
- b.identifier('platformProductDetailHref'),
2508
- [expr.right]
2509
- );
2510
- healed++;
2511
- needsImport = true;
2512
- }
2513
- }
2514
- }
2515
- this.traverse(pathNode);
2516
- },
2517
- });
2518
-
2519
- if (needsImport) {
2520
- ensureImport(ast, '@deneb-ui/ui', ['platformProductDetailHref']);
2521
- }
2522
- return healed;
2523
- }
2524
-
2525
- function healSectionOverflowHidden(ast) {
2526
- let healed = 0;
2527
- recast.types.visit(ast, {
2528
- visitJSXOpeningElement(pathNode) {
2529
- const node = pathNode.node;
2530
- const tag = getJsxName(node);
2531
- const lower = String(tag || '').toLowerCase();
2532
- const isContainer = lower === 'section' || lower === 'div' || lower === 'main' || lower === 'article';
2533
- if (!isContainer) {
2534
- this.traverse(pathNode);
2535
- return;
2536
- }
2537
- const classAttr = (node.attributes || []).find(
2538
- (a) => a.type === 'JSXAttribute' && a.name && (a.name.name === 'className' || a.name.name === 'class')
2539
- );
2540
- if (!classAttr || !classAttr.value) {
2541
- this.traverse(pathNode);
2542
- return;
2543
- }
2544
- let modified = false;
2545
- if (classAttr.value.type === 'StringLiteral' || classAttr.value.type === 'Literal') {
2546
- const val = String(classAttr.value.value || '');
2547
- if (/\boverflow-hidden\b/.test(val)) {
2548
- classAttr.value.value = val.replace(/\boverflow-hidden\b/g, 'overflow-clip');
2549
- modified = true;
2550
- }
2551
- } else if (classAttr.value.type === 'JSXExpressionContainer') {
2552
- const expr = classAttr.value.expression;
2553
- if (expr && (expr.type === 'StringLiteral' || expr.type === 'Literal')) {
2554
- const val = String(expr.value || '');
2555
- if (/\boverflow-hidden\b/.test(val)) {
2556
- expr.value = val.replace(/\boverflow-hidden\b/g, 'overflow-clip');
2557
- modified = true;
2558
- }
2559
- } else if (expr && expr.type === 'TemplateLiteral') {
2560
- for (const quasi of expr.quasis || []) {
2561
- if (quasi.value && /\boverflow-hidden\b/.test(quasi.value.raw || '')) {
2562
- quasi.value.raw = (quasi.value.raw || '').replace(/\boverflow-hidden\b/g, 'overflow-clip');
2563
- if (quasi.value.cooked) {
2564
- quasi.value.cooked = (quasi.value.cooked || '').replace(/\boverflow-hidden\b/g, 'overflow-clip');
2565
- }
2566
- modified = true;
2567
- }
2568
- }
2569
- }
2570
- }
2571
- if (modified) healed++;
2572
- this.traverse(pathNode);
2573
- },
2574
- });
2575
- return healed;
2576
- }
2577
-
2578
- function healDecorativeOverlays(ast) {
2579
- let healed = 0;
2580
- recast.types.visit(ast, {
2581
- visitJSXElement(pathNode) {
2582
- const node = pathNode.node;
2583
- const tag = getJsxName(node);
2584
- const lower = String(tag || '').toLowerCase();
2585
- if (lower !== 'div' && lower !== 'span') {
2586
- this.traverse(pathNode);
2587
- return;
2588
- }
2589
- const hasRealChildren = (node.children || []).some(
2590
- (c) => c.type === 'JSXElement' || (c.type === 'JSXText' && c.value.trim().length > 0)
2591
- );
2592
- if (hasRealChildren) {
2593
- this.traverse(pathNode);
2594
- return;
2595
- }
2596
- const classAttr = (node.openingElement.attributes || []).find(
2597
- (a) => a.type === 'JSXAttribute' && a.name && (a.name.name === 'className' || a.name.name === 'class')
2598
- );
2599
- if (!classAttr || !classAttr.value) {
2600
- this.traverse(pathNode);
2601
- return;
2602
- }
2603
- let modified = false;
2604
- const overlayPattern = /\b(?:bg-gradient-|bg-black\/|bg-white\/|bg-slate-\d+\/|backdrop-blur)/;
2605
- const isAbsolute = /\babsolute\b/;
2606
- const hasPointerEvents = /\bpointer-events-(?:none|auto)\b/;
2607
-
2608
- if (classAttr.value.type === 'StringLiteral' || classAttr.value.type === 'Literal') {
2609
- const val = String(classAttr.value.value || '');
2610
- if (isAbsolute.test(val) && overlayPattern.test(val) && !hasPointerEvents.test(val)) {
2611
- classAttr.value.value = `${val} pointer-events-none`;
2612
- modified = true;
2613
- }
2614
- } else if (classAttr.value.type === 'JSXExpressionContainer') {
2615
- const expr = classAttr.value.expression;
2616
- if (expr && (expr.type === 'StringLiteral' || expr.type === 'Literal')) {
2617
- const val = String(expr.value || '');
2618
- if (isAbsolute.test(val) && overlayPattern.test(val) && !hasPointerEvents.test(val)) {
2619
- expr.value = `${val} pointer-events-none`;
2620
- modified = true;
2621
- }
2622
- } else if (expr && expr.type === 'TemplateLiteral') {
2623
- const fullText = (expr.quasis || []).map((q) => q.value?.raw || '').join(' ');
2624
- if (isAbsolute.test(fullText) && overlayPattern.test(fullText) && !hasPointerEvents.test(fullText)) {
2625
- const lastQuasi = expr.quasis[expr.quasis.length - 1];
2626
- if (lastQuasi && lastQuasi.value) {
2627
- lastQuasi.value.raw = `${lastQuasi.value.raw} pointer-events-none`;
2628
- if (lastQuasi.value.cooked) {
2629
- lastQuasi.value.cooked = `${lastQuasi.value.cooked} pointer-events-none`;
2630
- }
2631
- modified = true;
2632
- }
2633
- }
2634
- }
2635
- }
2636
- if (modified) healed++;
2637
- this.traverse(pathNode);
2638
- },
2639
- });
2640
- return healed;
2641
- }
2642
-
2643
- function healMissingSiteDataHooks(code, filePath = 'file.tsx') {
2644
- if (!code.includes('siteData')) return code;
2645
- try {
2646
- const ast = parseSource(code, filePath);
2647
- const injected = injectSiteDataHook(ast);
2648
- if (injected) {
2649
- ensureImport(ast, '@deneb-ui/ui', ['useSiteData']);
2650
- return printSource(ast, code);
2651
- }
2652
- } catch {}
2653
- return code;
2654
- }
2655
-
2656
- function healUnguardedModalConditionals(ast) {
2657
- let healed = 0;
2658
- const stateVariables = [];
2659
- recast.types.visit(ast, {
2660
- visitVariableDeclarator(pathNode) {
2661
- const node = pathNode.node;
2662
- if (node.id && node.id.type === 'ArrayPattern') {
2663
- const first = node.id.elements[0];
2664
- if (first && first.type === 'Identifier') {
2665
- stateVariables.push(first.name);
2666
- }
2667
- }
2668
- this.traverse(pathNode);
2669
- },
2670
- });
2671
-
2672
- if (stateVariables.length === 0) return 0;
2673
-
2674
- recast.types.visit(ast, {
2675
- visitJSXElement(pathNode) {
2676
- const tag = getJsxName(pathNode.node);
2677
- if (tag === 'AnimatePresence') {
2678
- for (const child of pathNode.node.children || []) {
2679
- if (child.type === 'JSXExpressionContainer') {
2680
- let inner = child.expression;
2681
- if (inner && inner.type === 'ParenthesizedExpression') {
2682
- inner = inner.expression;
2683
- }
2684
- if (inner && (inner.type === 'JSXElement' || inner.type === 'JSXFragment')) {
2685
- const innerIds = new Set();
2686
- recast.types.visit(inner, {
2687
- visitIdentifier(idPath) {
2688
- innerIds.add(idPath.node.name);
2689
- return false;
2690
- },
2691
- });
2692
-
2693
- const matchingVar = stateVariables.find((sv) => innerIds.has(sv));
2694
- if (matchingVar) {
2695
- let guardExpr;
2696
- if (/index/i.test(matchingVar)) {
2697
- guardExpr = b.binaryExpression('!==', b.identifier(matchingVar), b.nullLiteral());
2698
- } else {
2699
- guardExpr = b.identifier(matchingVar);
2700
- }
2701
- child.expression = b.logicalExpression('&&', guardExpr, inner);
2702
- healed++;
2703
- }
2704
- }
2705
- }
2706
- }
2707
- }
2708
- this.traverse(pathNode);
2709
- },
2710
- });
2711
-
2712
- return healed;
2713
- }
2714
-
2715
- function healUnguardedModalConditionalsInSource(code, filePath = 'file.tsx') {
2716
- if (!code.includes('AnimatePresence') && !code.includes('fixed inset-0')) return { code, updated: false };
2717
- try {
2718
- const ast = parseSource(code, filePath);
2719
- const healed = healUnguardedModalConditionals(ast);
2720
- if (healed > 0) {
2721
- return { code: printSource(ast, code), updated: true, count: healed };
2722
- }
2723
- } catch {}
2724
- return { code, updated: false };
2725
- }
2726
-
2727
- module.exports = {
2728
- applyFilePlan,
2729
- applyCollectionTransform,
2730
- instrumentLayoutSource,
2731
- instrumentPageKey,
2732
- resolveSiteDataSpecifier,
2733
- resolveSiteDataRuntimeSpecifier,
2734
- rewriteRecursiveSiteDataContext,
2735
- ensureJsonModule,
2736
- inferPageKey,
2737
- sanitizeContradictoryMarkers,
2738
- sanitizeContradictoryMarkersInSource,
2739
- healBroadContainerMarkers,
2740
- healEmptyStateConditionals,
2741
- healHiddenPreviewMarkers,
2742
- healSectionOverflowHidden,
2743
- healDecorativeOverlays,
2744
- healLegacyProductDetailLinks,
2745
- healMissingSiteDataHooks,
2746
- healUnguardedModalConditionals,
2747
- healUnguardedModalConditionalsInSource,
2748
- injectSiteDataHook,
2749
- instrumentChildCardComponent,
2750
- };
2751
19
 
20
+ module.exports = require('./transforms/index.cjs');