@deneb-ui/cli 2.0.87 → 2.0.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/index.js +31 -5
- package/package.json +2 -2
- package/src/arc/__tests__/acceptance-gates.test.cjs +62 -0
- package/src/arc/__tests__/arc.test.cjs +922 -0
- package/src/arc/__tests__/canonical.test.cjs +57 -0
- package/src/arc/__tests__/classification.test.cjs +54 -0
- package/src/arc/__tests__/corpus.test.cjs +144 -0
- package/src/arc/__tests__/explain-blocked.test.cjs +192 -0
- package/src/arc/__tests__/fuzz.test.cjs +106 -0
- package/src/arc/__tests__/interaction.test.cjs +85 -0
- package/src/arc/__tests__/visual-regression.test.cjs +99 -0
- package/src/arc/acceptance-gates.cjs +119 -0
- package/src/arc/canonical-paths.cjs +142 -23
- package/src/arc/corpus-verifier.cjs +295 -0
- package/src/arc/data-classification.cjs +182 -0
- package/src/arc/data-flow.cjs +62 -2
- package/src/arc/explain-blocked.cjs +388 -0
- package/src/arc/fuzz-engine.cjs +604 -0
- package/src/arc/index.cjs +148 -2
- package/src/arc/interaction-verifier.cjs +185 -0
- package/src/arc/ir-builder.cjs +170 -12
- package/src/arc/planner.cjs +16 -1
- package/src/arc/residual.cjs +22 -0
- package/src/arc/rsc-boundary.cjs +105 -4
- package/src/arc/runtime-validator.cjs +65 -0
- package/src/arc/semantic.cjs +12 -1
- package/src/arc/transformer.cjs +14 -2745
- package/src/arc/transforms/assets/tailwind-bg.cjs +38 -0
- package/src/arc/transforms/collections/list.cjs +474 -0
- package/src/arc/transforms/components/child-card.cjs +268 -0
- package/src/arc/transforms/components/compound-content.cjs +49 -0
- package/src/arc/transforms/components/reusable-component.cjs +173 -0
- package/src/arc/transforms/element-transform.cjs +192 -0
- package/src/arc/transforms/healing/conditionals.cjs +295 -0
- package/src/arc/transforms/healing/sanitizer.cjs +401 -0
- package/src/arc/transforms/index.cjs +195 -0
- package/src/arc/transforms/primitives/action.cjs +76 -0
- package/src/arc/transforms/primitives/text.cjs +65 -0
- package/src/arc/transforms/routing/page-key.cjs +84 -0
- package/src/arc/transforms/routing/shared-layout.cjs +294 -0
- package/src/arc/transforms/runtime/provider.cjs +230 -0
- package/src/arc/transforms/transform-context.cjs +118 -0
- package/src/arc/types/acceptance.d.ts +50 -0
- package/src/arc/types/canonical.d.ts +50 -0
- package/src/arc/types/classification.d.ts +42 -0
- package/src/arc/types/corpus.d.ts +52 -0
- package/src/arc/types/explain-blocked.d.ts +41 -0
- package/src/arc/types/fuzz.d.ts +74 -0
- package/src/arc/types/interaction.d.ts +43 -0
- package/src/arc/types/ir.d.ts +114 -0
- package/src/arc/types/rsc.d.ts +39 -0
- package/src/arc/types/visual-regression.d.ts +49 -0
- package/src/arc/visual-regression.cjs +195 -0
- package/src/arc/workspace.cjs +233 -0
- package/src/tools/deneb-template-validator.cjs +17 -17
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const recast = require('recast');
|
|
4
|
+
const {
|
|
5
|
+
parseSource,
|
|
6
|
+
printSource,
|
|
7
|
+
getJsxName,
|
|
8
|
+
hasJsxAttribute,
|
|
9
|
+
ensureImport,
|
|
10
|
+
ensureDefaultImport,
|
|
11
|
+
sanitizeDuplicateBindings,
|
|
12
|
+
b,
|
|
13
|
+
} = require('../../ast.cjs');
|
|
14
|
+
|
|
15
|
+
function ensureHtmlBodyHydration(ast) {
|
|
16
|
+
recast.types.visit(ast, {
|
|
17
|
+
visitJSXOpeningElement(pathNode) {
|
|
18
|
+
const name = pathNode.node.name;
|
|
19
|
+
const tag = name && name.type === 'JSXIdentifier' ? name.name : '';
|
|
20
|
+
if (tag === 'html' || tag === 'body') {
|
|
21
|
+
const has = (pathNode.node.attributes || []).some(
|
|
22
|
+
(attr) => attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'suppressHydrationWarning',
|
|
23
|
+
);
|
|
24
|
+
if (!has) {
|
|
25
|
+
pathNode.node.attributes.push(b.jsxAttribute(b.jsxIdentifier('suppressHydrationWarning')));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
this.traverse(pathNode);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function findSiteDataJsonLocalName(ast) {
|
|
34
|
+
const program = ast.program || ast;
|
|
35
|
+
for (const node of program.body || []) {
|
|
36
|
+
if (node.type !== 'ImportDeclaration') continue;
|
|
37
|
+
const src = node.source && node.source.value;
|
|
38
|
+
if (typeof src !== 'string' || !/site-data\.json/.test(src)) continue;
|
|
39
|
+
const spec = (node.specifiers || []).find((s) => s.type === 'ImportDefaultSpecifier');
|
|
40
|
+
if (spec && spec.local) return spec.local.name;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ensureProviderInitialData(ast, ident) {
|
|
46
|
+
let added = false;
|
|
47
|
+
recast.types.visit(ast, {
|
|
48
|
+
visitJSXOpeningElement(pathNode) {
|
|
49
|
+
const name = pathNode.node.name;
|
|
50
|
+
const tag = name && name.type === 'JSXIdentifier' ? name.name : '';
|
|
51
|
+
if (tag === 'SiteDataProvider' || tag === 'DenebDataProvider' || tag === 'DenebSiteDataProvider') {
|
|
52
|
+
const has = hasJsxAttribute(pathNode.node, 'initialSiteData');
|
|
53
|
+
if (!has) {
|
|
54
|
+
pathNode.node.attributes = pathNode.node.attributes || [];
|
|
55
|
+
pathNode.node.attributes.push(
|
|
56
|
+
b.jsxAttribute(
|
|
57
|
+
b.jsxIdentifier('initialSiteData'),
|
|
58
|
+
b.jsxExpressionContainer(b.identifier(ident))
|
|
59
|
+
)
|
|
60
|
+
);
|
|
61
|
+
added = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
this.traverse(pathNode);
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
return added;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function injectDualModeTheme(ast, jsonIdent, providerImport = '@deneb-ui/ui') {
|
|
71
|
+
if (!ast) return;
|
|
72
|
+
let hasThemeStyles = false;
|
|
73
|
+
let hasThemeToggle = false;
|
|
74
|
+
recast.types.visit(ast, {
|
|
75
|
+
visitJSXIdentifier(pathNode) {
|
|
76
|
+
if (pathNode.node.name === 'ThemeStyles') hasThemeStyles = true;
|
|
77
|
+
if (pathNode.node.name === 'ThemeToggle') hasThemeToggle = true;
|
|
78
|
+
this.traverse(pathNode);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const importsToAdd = [];
|
|
83
|
+
if (!hasThemeStyles) importsToAdd.push('ThemeStyles');
|
|
84
|
+
if (!hasThemeToggle) importsToAdd.push('ThemeToggle');
|
|
85
|
+
if (importsToAdd.length === 0) return;
|
|
86
|
+
|
|
87
|
+
if (!hasThemeStyles) {
|
|
88
|
+
let stylesInjected = false;
|
|
89
|
+
const snippet = parseSource(
|
|
90
|
+
`<ThemeStyles theme={${jsonIdent}?.template?.structure?.theme || ${jsonIdent}?.theme} enableDualMode />`,
|
|
91
|
+
'snippet.tsx'
|
|
92
|
+
);
|
|
93
|
+
const stylesEl = snippet.program.body[0].expression;
|
|
94
|
+
|
|
95
|
+
// Try <head> first
|
|
96
|
+
recast.types.visit(ast, {
|
|
97
|
+
visitJSXElement(pathNode) {
|
|
98
|
+
if (stylesInjected) return false;
|
|
99
|
+
const name = getJsxName(pathNode.node);
|
|
100
|
+
if (name === 'head' || name === 'Head') {
|
|
101
|
+
pathNode.node.children = [b.jsxText('\n '), stylesEl, ...(pathNode.node.children || [])];
|
|
102
|
+
stylesInjected = true;
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
this.traverse(pathNode);
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// If no <head>, inject inside SiteDataProvider or <body>
|
|
110
|
+
if (!stylesInjected) {
|
|
111
|
+
recast.types.visit(ast, {
|
|
112
|
+
visitJSXElement(pathNode) {
|
|
113
|
+
if (stylesInjected) return false;
|
|
114
|
+
const name = getJsxName(pathNode.node);
|
|
115
|
+
if (name === 'SiteDataProvider' || name === 'body') {
|
|
116
|
+
pathNode.node.children = [b.jsxText('\n '), stylesEl, ...(pathNode.node.children || [])];
|
|
117
|
+
stylesInjected = true;
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
this.traverse(pathNode);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!hasThemeToggle) {
|
|
127
|
+
let toggleInjected = false;
|
|
128
|
+
const snippet = parseSource(
|
|
129
|
+
'<ThemeToggle showLabel className="fixed bottom-6 left-6 z-40" />',
|
|
130
|
+
'snippet.tsx'
|
|
131
|
+
);
|
|
132
|
+
const toggleEl = snippet.program.body[0].expression;
|
|
133
|
+
|
|
134
|
+
recast.types.visit(ast, {
|
|
135
|
+
visitJSXElement(pathNode) {
|
|
136
|
+
if (toggleInjected) return false;
|
|
137
|
+
const name = getJsxName(pathNode.node);
|
|
138
|
+
if (name === 'SiteDataProvider' || name === 'body') {
|
|
139
|
+
pathNode.node.children = [...(pathNode.node.children || []), b.jsxText('\n '), toggleEl];
|
|
140
|
+
toggleInjected = true;
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
this.traverse(pathNode);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
ensureImport(ast, providerImport || '@deneb-ui/ui', importsToAdd);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function injectPlatformAdditionalPages(ast, providerImport) {
|
|
152
|
+
if (!ast) return;
|
|
153
|
+
let alreadyPresent = false;
|
|
154
|
+
recast.types.visit(ast, {
|
|
155
|
+
visitJSXIdentifier(pathNode) {
|
|
156
|
+
if (pathNode.node.name === 'PlatformAdditionalPages') {
|
|
157
|
+
alreadyPresent = true;
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
this.traverse(pathNode);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
if (alreadyPresent) return;
|
|
164
|
+
|
|
165
|
+
let injected = false;
|
|
166
|
+
recast.types.visit(ast, {
|
|
167
|
+
visitJSXElement(pathNode) {
|
|
168
|
+
if (injected) return false;
|
|
169
|
+
const name = getJsxName(pathNode.node);
|
|
170
|
+
if (name === 'main') {
|
|
171
|
+
const parent = pathNode.parent;
|
|
172
|
+
if (!parent || !Array.isArray(parent.node.children)) {
|
|
173
|
+
this.traverse(pathNode);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const siblings = parent.node.children;
|
|
177
|
+
const idx = siblings.indexOf(pathNode.node);
|
|
178
|
+
if (idx === -1) {
|
|
179
|
+
this.traverse(pathNode);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const platformElement = b.jsxElement(
|
|
183
|
+
b.jsxOpeningElement(
|
|
184
|
+
b.jsxIdentifier('PlatformAdditionalPages'),
|
|
185
|
+
[],
|
|
186
|
+
true
|
|
187
|
+
),
|
|
188
|
+
null,
|
|
189
|
+
[],
|
|
190
|
+
true
|
|
191
|
+
);
|
|
192
|
+
const newline = b.jsxText('\n ');
|
|
193
|
+
siblings.splice(idx + 1, 0, newline, platformElement);
|
|
194
|
+
ensureImport(ast, providerImport || '@deneb-ui/ui', ['PlatformAdditionalPages']);
|
|
195
|
+
injected = true;
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
this.traverse(pathNode);
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function instrumentLayoutSource(code, siteDataImport, providerImport = '@deneb-ui/ui') {
|
|
204
|
+
const ast = parseSource(code, 'layout.tsx');
|
|
205
|
+
ensureHtmlBodyHydration(ast);
|
|
206
|
+
|
|
207
|
+
let jsonIdent = findSiteDataJsonLocalName(ast);
|
|
208
|
+
if (!jsonIdent) {
|
|
209
|
+
jsonIdent = 'initialSiteData';
|
|
210
|
+
ensureDefaultImport(ast, siteDataImport, jsonIdent);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const hasProvider = /SiteDataProvider|DenebDataProvider|<Providers\b/.test(code);
|
|
214
|
+
let wrapped = hasProvider;
|
|
215
|
+
|
|
216
|
+
if (!hasProvider) {
|
|
217
|
+
ensureImport(ast, providerImport, ['SiteDataProvider']);
|
|
218
|
+
recast.types.visit(ast, {
|
|
219
|
+
visitJSXExpressionContainer(pathNode) {
|
|
220
|
+
if (wrapped) return false;
|
|
221
|
+
const expr = pathNode.node.expression;
|
|
222
|
+
if (expr && expr.type === 'Identifier' && expr.name === 'children') {
|
|
223
|
+
pathNode.replace(
|
|
224
|
+
b.jsxElement(
|
|
225
|
+
b.jsxOpeningElement(
|
|
226
|
+
b.jsxIdentifier('SiteDataProvider'),
|
|
227
|
+
[
|
|
228
|
+
b.jsxAttribute(
|
|
229
|
+
b.jsxIdentifier('initialSiteData'),
|
|
230
|
+
b.jsxExpressionContainer(b.identifier(jsonIdent))
|
|
231
|
+
),
|
|
232
|
+
],
|
|
233
|
+
false
|
|
234
|
+
),
|
|
235
|
+
b.jsxClosingElement(b.jsxIdentifier('SiteDataProvider')),
|
|
236
|
+
[pathNode.node],
|
|
237
|
+
false
|
|
238
|
+
)
|
|
239
|
+
);
|
|
240
|
+
wrapped = true;
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
this.traverse(pathNode);
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
if (!wrapped) {
|
|
248
|
+
recast.types.visit(ast, {
|
|
249
|
+
visitJSXElement(pathNode) {
|
|
250
|
+
if (wrapped) return false;
|
|
251
|
+
const name = getJsxName(pathNode.node);
|
|
252
|
+
if (name === 'Component') {
|
|
253
|
+
pathNode.replace(
|
|
254
|
+
b.jsxElement(
|
|
255
|
+
b.jsxOpeningElement(
|
|
256
|
+
b.jsxIdentifier('SiteDataProvider'),
|
|
257
|
+
[
|
|
258
|
+
b.jsxAttribute(
|
|
259
|
+
b.jsxIdentifier('initialSiteData'),
|
|
260
|
+
b.jsxExpressionContainer(b.identifier(jsonIdent))
|
|
261
|
+
),
|
|
262
|
+
],
|
|
263
|
+
false
|
|
264
|
+
),
|
|
265
|
+
b.jsxClosingElement(b.jsxIdentifier('SiteDataProvider')),
|
|
266
|
+
[pathNode.node],
|
|
267
|
+
false
|
|
268
|
+
)
|
|
269
|
+
);
|
|
270
|
+
wrapped = true;
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
this.traverse(pathNode);
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
ensureProviderInitialData(ast, jsonIdent);
|
|
280
|
+
injectPlatformAdditionalPages(ast, providerImport);
|
|
281
|
+
injectDualModeTheme(ast, jsonIdent, providerImport);
|
|
282
|
+
sanitizeDuplicateBindings(ast);
|
|
283
|
+
const next = printSource(ast, code);
|
|
284
|
+
return { code: next, updated: next !== code };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
instrumentLayoutSource,
|
|
289
|
+
ensureHtmlBodyHydration,
|
|
290
|
+
findSiteDataJsonLocalName,
|
|
291
|
+
ensureProviderInitialData,
|
|
292
|
+
injectDualModeTheme,
|
|
293
|
+
injectPlatformAdditionalPages,
|
|
294
|
+
};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const recast = require('recast');
|
|
6
|
+
const { b } = require('../../ast.cjs');
|
|
7
|
+
|
|
8
|
+
const CANONICAL_SITE_DATA_CONTEXT = `'use client';
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
SiteDataProvider,
|
|
12
|
+
useSiteData,
|
|
13
|
+
contentText,
|
|
14
|
+
contentObject,
|
|
15
|
+
contentList,
|
|
16
|
+
PREVIEW_DATA_MESSAGE,
|
|
17
|
+
LEGACY_PREVIEW_DATA_MESSAGE,
|
|
18
|
+
PREVIEW_READY_MESSAGE,
|
|
19
|
+
LEGACY_PREVIEW_READY_MESSAGE,
|
|
20
|
+
PREVIEW_FOCUS_MESSAGE,
|
|
21
|
+
LEGACY_PREVIEW_FOCUS_MESSAGE,
|
|
22
|
+
PREVIEW_FIELD_ATTRIBUTE,
|
|
23
|
+
} from '@deneb-ui/ui';
|
|
24
|
+
|
|
25
|
+
export type { SiteData, SiteDataProviderProps } from '@deneb-ui/ui';
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
function fileAlreadyUsesSiteDataHook(ast) {
|
|
29
|
+
let found = false;
|
|
30
|
+
recast.types.visit(ast, {
|
|
31
|
+
visitCallExpression(pathNode) {
|
|
32
|
+
const callee = pathNode.node.callee;
|
|
33
|
+
if (callee && callee.type === 'Identifier' && callee.name === 'useSiteData') {
|
|
34
|
+
found = true;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
this.traverse(pathNode);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveSiteDataRuntimeSpecifier(profile) {
|
|
44
|
+
const root = profile && profile.root;
|
|
45
|
+
if (!root) return '@deneb-ui/ui';
|
|
46
|
+
const candidates = [
|
|
47
|
+
['src/lib/siteDataContext.tsx', '@/lib/siteDataContext'],
|
|
48
|
+
['src/lib/siteDataContext.ts', '@/lib/siteDataContext'],
|
|
49
|
+
['lib/siteDataContext.tsx', '@/lib/siteDataContext'],
|
|
50
|
+
['lib/siteDataContext.ts', '@/lib/siteDataContext'],
|
|
51
|
+
];
|
|
52
|
+
const hasAt = Object.keys(profile.aliasMap || {}).some((k) => k === '@/*' || k.startsWith('@/'));
|
|
53
|
+
for (const [relative, alias] of candidates) {
|
|
54
|
+
if (!fs.existsSync(path.join(root, relative))) continue;
|
|
55
|
+
if (hasAt) return alias;
|
|
56
|
+
const fromAbs = path.join(root, 'src/components/placeholder.tsx');
|
|
57
|
+
const toAbs = path.join(root, relative.replace(/\.tsx?$/, ''));
|
|
58
|
+
let relSpec = path.relative(path.dirname(fromAbs), toAbs).replace(/\\/g, '/');
|
|
59
|
+
if (!relSpec.startsWith('.')) relSpec = './' + relSpec;
|
|
60
|
+
return relSpec;
|
|
61
|
+
}
|
|
62
|
+
return '@deneb-ui/ui';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function functionReferencesSiteData(fn) {
|
|
66
|
+
if (!fn || !fn.body) return false;
|
|
67
|
+
let found = false;
|
|
68
|
+
recast.types.visit(fn.body, {
|
|
69
|
+
visitIdentifier(pathNode) {
|
|
70
|
+
if (pathNode.node.name === 'siteData') {
|
|
71
|
+
found = true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
this.traverse(pathNode);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
return found;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function injectSiteDataHook(ast) {
|
|
81
|
+
let injected = false;
|
|
82
|
+
let anyFunctionReferencedSiteData = false;
|
|
83
|
+
let alreadyHasUseSiteData = false;
|
|
84
|
+
|
|
85
|
+
recast.types.visit(ast, {
|
|
86
|
+
visitCallExpression(pathNode) {
|
|
87
|
+
if (pathNode.node.callee && pathNode.node.callee.name === 'useSiteData') {
|
|
88
|
+
alreadyHasUseSiteData = true;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
this.traverse(pathNode);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function injectIntoFunction(fn) {
|
|
96
|
+
if (!fn || !fn.body || fn.body.type !== 'BlockStatement') return false;
|
|
97
|
+
const body = fn.body.body;
|
|
98
|
+
const already = body.some((stmt) => recast.print(stmt).code.includes('useSiteData'));
|
|
99
|
+
if (already) return false;
|
|
100
|
+
const hook = b.variableDeclaration('const', [
|
|
101
|
+
b.variableDeclarator(
|
|
102
|
+
b.identifier('siteData'),
|
|
103
|
+
b.callExpression(b.identifier('useSiteData'), [])
|
|
104
|
+
),
|
|
105
|
+
]);
|
|
106
|
+
body.unshift(hook);
|
|
107
|
+
alreadyHasUseSiteData = true;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Pass 1: Inject useSiteData() into EVERY component function that references siteData
|
|
112
|
+
recast.types.visit(ast, {
|
|
113
|
+
visitFunctionDeclaration(pathNode) {
|
|
114
|
+
if (functionReferencesSiteData(pathNode.node)) {
|
|
115
|
+
anyFunctionReferencedSiteData = true;
|
|
116
|
+
if (injectIntoFunction(pathNode.node)) injected = true;
|
|
117
|
+
}
|
|
118
|
+
this.traverse(pathNode);
|
|
119
|
+
},
|
|
120
|
+
visitFunctionExpression(pathNode) {
|
|
121
|
+
if (functionReferencesSiteData(pathNode.node)) {
|
|
122
|
+
anyFunctionReferencedSiteData = true;
|
|
123
|
+
if (injectIntoFunction(pathNode.node)) injected = true;
|
|
124
|
+
}
|
|
125
|
+
this.traverse(pathNode);
|
|
126
|
+
},
|
|
127
|
+
visitArrowFunctionExpression(pathNode) {
|
|
128
|
+
if (functionReferencesSiteData(pathNode.node)) {
|
|
129
|
+
anyFunctionReferencedSiteData = true;
|
|
130
|
+
if (injectIntoFunction(pathNode.node)) injected = true;
|
|
131
|
+
}
|
|
132
|
+
this.traverse(pathNode);
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Pass 2: If no component references siteData yet (e.g. before subsequent AST edits),
|
|
137
|
+
// ensure the primary component receives useSiteData()
|
|
138
|
+
if (!injected && !anyFunctionReferencedSiteData && !alreadyHasUseSiteData) {
|
|
139
|
+
recast.types.visit(ast, {
|
|
140
|
+
visitExportDefaultDeclaration(pathNode) {
|
|
141
|
+
if (injected) return false;
|
|
142
|
+
const decl = pathNode.node.declaration;
|
|
143
|
+
if (decl && (decl.type === 'FunctionDeclaration' || decl.type === 'ArrowFunctionExpression' || decl.type === 'FunctionExpression')) {
|
|
144
|
+
injected = injectIntoFunction(decl);
|
|
145
|
+
}
|
|
146
|
+
this.traverse(pathNode);
|
|
147
|
+
},
|
|
148
|
+
visitFunctionDeclaration(pathNode) {
|
|
149
|
+
if (injected) return false;
|
|
150
|
+
const name = pathNode.node.id && pathNode.node.id.name;
|
|
151
|
+
if (name && /^[A-Z]/.test(name)) {
|
|
152
|
+
injected = injectIntoFunction(pathNode.node);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
this.traverse(pathNode);
|
|
156
|
+
},
|
|
157
|
+
visitVariableDeclarator(pathNode) {
|
|
158
|
+
if (injected) return false;
|
|
159
|
+
const id = pathNode.node.id;
|
|
160
|
+
const init = pathNode.node.init;
|
|
161
|
+
if (id && id.type === 'Identifier' && /^[A-Z]/.test(id.name) && init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) {
|
|
162
|
+
injected = injectIntoFunction(init);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
this.traverse(pathNode);
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!injected && !anyFunctionReferencedSiteData && !alreadyHasUseSiteData) {
|
|
171
|
+
recast.types.visit(ast, {
|
|
172
|
+
visitFunctionDeclaration(pathNode) {
|
|
173
|
+
if (injected) return false;
|
|
174
|
+
injected = injectIntoFunction(pathNode.node);
|
|
175
|
+
return false;
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return injected;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function resolveSiteDataSpecifier(profile, fromRelativeFile) {
|
|
184
|
+
const aliases = (profile && profile.aliasMap) || {};
|
|
185
|
+
const hasAt = Object.keys(aliases).some((k) => k === '@/*' || k.startsWith('@/'));
|
|
186
|
+
const hasSrc = Boolean(profile && profile.hasSrc);
|
|
187
|
+
const siteDataRel = hasSrc ? 'src/data/site-data.json' : 'data/site-data.json';
|
|
188
|
+
if (hasAt && hasSrc) return '@/data/site-data.json';
|
|
189
|
+
|
|
190
|
+
const root = profile && profile.root ? profile.root : process.cwd();
|
|
191
|
+
const fromAbs = path.join(root, fromRelativeFile || 'page.tsx');
|
|
192
|
+
const toAbs = path.join(root, siteDataRel);
|
|
193
|
+
let relSpec = path.relative(path.dirname(fromAbs), toAbs).replace(/\\/g, '/');
|
|
194
|
+
if (!relSpec.startsWith('.')) relSpec = './' + relSpec;
|
|
195
|
+
return relSpec;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function rewriteRecursiveSiteDataContext(code) {
|
|
199
|
+
const recursive =
|
|
200
|
+
/export\s+function\s+SiteDataProvider\b/.test(code) &&
|
|
201
|
+
/<(?:Base)?SiteDataProvider\b/.test(code);
|
|
202
|
+
if (!recursive) return { code, updated: false };
|
|
203
|
+
return { code: CANONICAL_SITE_DATA_CONTEXT, updated: true };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function ensureJsonModule(tsconfig) {
|
|
207
|
+
if (!tsconfig || !tsconfig.compilerOptions) return { config: tsconfig, changed: false };
|
|
208
|
+
if (tsconfig.compilerOptions.resolveJsonModule) return { config: tsconfig, changed: false };
|
|
209
|
+
return {
|
|
210
|
+
config: {
|
|
211
|
+
...tsconfig,
|
|
212
|
+
compilerOptions: {
|
|
213
|
+
...tsconfig.compilerOptions,
|
|
214
|
+
resolveJsonModule: true,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
changed: true,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
CANONICAL_SITE_DATA_CONTEXT,
|
|
223
|
+
fileAlreadyUsesSiteDataHook,
|
|
224
|
+
resolveSiteDataRuntimeSpecifier,
|
|
225
|
+
functionReferencesSiteData,
|
|
226
|
+
injectSiteDataHook,
|
|
227
|
+
resolveSiteDataSpecifier,
|
|
228
|
+
rewriteRecursiveSiteDataContext,
|
|
229
|
+
ensureJsonModule,
|
|
230
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const recast = require('recast');
|
|
4
|
+
const {
|
|
5
|
+
locKey,
|
|
6
|
+
getJsxName,
|
|
7
|
+
findJsxAttribute,
|
|
8
|
+
hasJsxAttribute,
|
|
9
|
+
jsxPreviewAttr,
|
|
10
|
+
b,
|
|
11
|
+
} = require('../ast.cjs');
|
|
12
|
+
|
|
13
|
+
function findElementByLoc(ast, loc, tagName = null) {
|
|
14
|
+
if (!loc && !tagName) return null;
|
|
15
|
+
const parts = loc ? loc.split(':') : [];
|
|
16
|
+
const targetLoc = parts.length > 4 ? parts.slice(0, 4).join(':') : loc;
|
|
17
|
+
let found = null;
|
|
18
|
+
recast.types.visit(ast, {
|
|
19
|
+
visitJSXElement(pathNode) {
|
|
20
|
+
const k = locKey(pathNode.node);
|
|
21
|
+
const name = getJsxName(pathNode.node);
|
|
22
|
+
if (loc && (k === targetLoc || k === loc || (parts.length >= 2 && k && k.startsWith(`${parts[0]}:${parts[1]}`)))) {
|
|
23
|
+
found = pathNode;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (!loc && tagName && (name === tagName || name.endsWith(`.${tagName}`))) {
|
|
27
|
+
found = pathNode;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
this.traverse(pathNode);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function replaceAttrValue(node, attrName, expression) {
|
|
37
|
+
const attr = findJsxAttribute(node, attrName);
|
|
38
|
+
if (!attr) {
|
|
39
|
+
node.openingElement.attributes.push(
|
|
40
|
+
b.jsxAttribute(b.jsxIdentifier(attrName), b.jsxExpressionContainer(expression))
|
|
41
|
+
);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
attr.value = b.jsxExpressionContainer(expression);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stripStaticAttribute(node) {
|
|
48
|
+
if (node && node.openingElement && Array.isArray(node.openingElement.attributes)) {
|
|
49
|
+
node.openingElement.attributes = node.openingElement.attributes.filter(
|
|
50
|
+
(attr) => !(attr.type === 'JSXAttribute' && attr.name && attr.name.name === 'data-preview-static')
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ensurePreviewPath(node, fieldPath) {
|
|
56
|
+
stripStaticAttribute(node);
|
|
57
|
+
if (hasJsxAttribute(node, 'data-preview-field-path')) return;
|
|
58
|
+
node.openingElement.attributes.push(jsxPreviewAttr(fieldPath));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function findAncestorJsxElement(pathNode) {
|
|
62
|
+
let current = pathNode.parent;
|
|
63
|
+
while (current) {
|
|
64
|
+
const candidate = current.node || current.value;
|
|
65
|
+
if (candidate?.type === 'JSXElement') return candidate;
|
|
66
|
+
current = current.parentPath || current.parent;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function inferButtonKind(tag) {
|
|
72
|
+
if (tag === 'button' || tag === 'Button') return 'button';
|
|
73
|
+
return 'text';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isModuleLevel(pathNode) {
|
|
77
|
+
let current = pathNode.parentPath || pathNode.parent;
|
|
78
|
+
while (current) {
|
|
79
|
+
const type = current.node?.type;
|
|
80
|
+
if (
|
|
81
|
+
type === 'FunctionDeclaration' ||
|
|
82
|
+
type === 'FunctionExpression' ||
|
|
83
|
+
type === 'ArrowFunctionExpression'
|
|
84
|
+
) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (type === 'Program') return true;
|
|
88
|
+
current = current.parentPath || current.parent;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function findEnclosingFunction(pathNode) {
|
|
94
|
+
let current = pathNode.parentPath || pathNode.parent;
|
|
95
|
+
while (current) {
|
|
96
|
+
const type = current.node?.type;
|
|
97
|
+
if (
|
|
98
|
+
type === 'FunctionDeclaration' ||
|
|
99
|
+
type === 'FunctionExpression' ||
|
|
100
|
+
type === 'ArrowFunctionExpression'
|
|
101
|
+
) {
|
|
102
|
+
return current;
|
|
103
|
+
}
|
|
104
|
+
current = current.parentPath || current.parent;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
findElementByLoc,
|
|
111
|
+
replaceAttrValue,
|
|
112
|
+
stripStaticAttribute,
|
|
113
|
+
ensurePreviewPath,
|
|
114
|
+
findAncestorJsxElement,
|
|
115
|
+
inferButtonKind,
|
|
116
|
+
isModuleLevel,
|
|
117
|
+
findEnclosingFunction,
|
|
118
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type AcceptanceGateName =
|
|
2
|
+
| 'Source Analysis'
|
|
3
|
+
| 'AST Transformation'
|
|
4
|
+
| 'TypeScript / JS Validation'
|
|
5
|
+
| 'Next.js Build'
|
|
6
|
+
| 'Fivora Contract'
|
|
7
|
+
| 'Manifest / Site Data'
|
|
8
|
+
| 'Runtime Data Binding'
|
|
9
|
+
| 'Runtime Collection Operations'
|
|
10
|
+
| 'Runtime Image Editing'
|
|
11
|
+
| 'Route Coverage'
|
|
12
|
+
| 'Design Preservation'
|
|
13
|
+
| 'Control-Only Protection';
|
|
14
|
+
|
|
15
|
+
export interface GateResult {
|
|
16
|
+
name: AcceptanceGateName;
|
|
17
|
+
passed: boolean;
|
|
18
|
+
critical: boolean;
|
|
19
|
+
details?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ConversionStatus = 'CONVERSION_PASSED' | 'CONVERSION_INCOMPLETE' | 'CONVERSION_FAILED';
|
|
23
|
+
|
|
24
|
+
export interface AcceptanceEvaluation {
|
|
25
|
+
passed: boolean;
|
|
26
|
+
status: ConversionStatus;
|
|
27
|
+
gates: Record<string, GateResult>;
|
|
28
|
+
allCriticalPassed: boolean;
|
|
29
|
+
editabilityCoverage: number;
|
|
30
|
+
designPreservation: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export declare const ACCEPTANCE_GATES: Record<string, AcceptanceGateName>;
|
|
34
|
+
|
|
35
|
+
export declare function evaluateAcceptanceGates(metrics: {
|
|
36
|
+
sourceAnalysis?: { passed?: boolean };
|
|
37
|
+
astTransformation?: { passed?: boolean };
|
|
38
|
+
typescriptValidation?: { passed?: boolean };
|
|
39
|
+
buildSuccess?: { passed?: boolean };
|
|
40
|
+
fivoraAudit?: { passed?: boolean };
|
|
41
|
+
fivoraContract?: { passed?: boolean };
|
|
42
|
+
manifestValid?: boolean;
|
|
43
|
+
runtimeEditabilityScore?: number;
|
|
44
|
+
collectionOpsPassed?: boolean;
|
|
45
|
+
imageEditingPassed?: boolean;
|
|
46
|
+
routeCoveragePassed?: boolean;
|
|
47
|
+
designPreservationScore?: number;
|
|
48
|
+
controlOnlyValid?: boolean;
|
|
49
|
+
editabilityCoverage?: number;
|
|
50
|
+
}): AcceptanceEvaluation;
|