@deneb-ui/cli 2.0.21 → 2.0.23

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