@dropins/mcp 0.1.0
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/LICENSE.md +127 -0
- package/README.md +314 -0
- package/dist/common/project-reader.d.ts +55 -0
- package/dist/common/project-reader.js +173 -0
- package/dist/common/registry-loader.d.ts +101 -0
- package/dist/common/registry-loader.js +386 -0
- package/dist/common/response-handling.d.ts +12 -0
- package/dist/common/response-handling.js +21 -0
- package/dist/common/sanitize.d.ts +8 -0
- package/dist/common/sanitize.js +45 -0
- package/dist/common/synonyms.d.ts +9 -0
- package/dist/common/synonyms.js +127 -0
- package/dist/common/telemetry.d.ts +14 -0
- package/dist/common/telemetry.js +54 -0
- package/dist/common/types.d.ts +308 -0
- package/dist/common/types.js +1 -0
- package/dist/common/version.d.ts +2 -0
- package/dist/common/version.js +14 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +136 -0
- package/dist/operations/analyze-project.d.ts +13 -0
- package/dist/operations/analyze-project.js +125 -0
- package/dist/operations/check-block-health.d.ts +19 -0
- package/dist/operations/check-block-health.js +1149 -0
- package/dist/operations/check-config.d.ts +13 -0
- package/dist/operations/check-config.js +228 -0
- package/dist/operations/explain-event-flow.d.ts +16 -0
- package/dist/operations/explain-event-flow.js +218 -0
- package/dist/operations/get-upgrade-diff.d.ts +13 -0
- package/dist/operations/get-upgrade-diff.js +144 -0
- package/dist/operations/list-api-functions.d.ts +13 -0
- package/dist/operations/list-api-functions.js +53 -0
- package/dist/operations/list-containers.d.ts +13 -0
- package/dist/operations/list-containers.js +44 -0
- package/dist/operations/list-design-tokens.d.ts +13 -0
- package/dist/operations/list-design-tokens.js +47 -0
- package/dist/operations/list-events.d.ts +16 -0
- package/dist/operations/list-events.js +39 -0
- package/dist/operations/list-graphql-queries.d.ts +19 -0
- package/dist/operations/list-graphql-queries.js +84 -0
- package/dist/operations/list-i18n-keys.d.ts +19 -0
- package/dist/operations/list-i18n-keys.js +105 -0
- package/dist/operations/list-models.d.ts +16 -0
- package/dist/operations/list-models.js +80 -0
- package/dist/operations/list-slots.d.ts +16 -0
- package/dist/operations/list-slots.js +81 -0
- package/dist/operations/scaffold-block.d.ts +31 -0
- package/dist/operations/scaffold-block.js +331 -0
- package/dist/operations/scaffold-extension.d.ts +28 -0
- package/dist/operations/scaffold-extension.js +346 -0
- package/dist/operations/scaffold-slot.d.ts +22 -0
- package/dist/operations/scaffold-slot.js +189 -0
- package/dist/operations/search-commerce-docs.d.ts +16 -0
- package/dist/operations/search-commerce-docs.js +101 -0
- package/dist/operations/search-docs.d.ts +23 -0
- package/dist/operations/search-docs.js +298 -0
- package/dist/operations/suggest-event-handler.d.ts +16 -0
- package/dist/operations/suggest-event-handler.js +175 -0
- package/dist/operations/suggest-slot-implementation.d.ts +19 -0
- package/dist/operations/suggest-slot-implementation.js +183 -0
- package/dist/registry/api-functions.json +3045 -0
- package/dist/registry/block-patterns.json +78 -0
- package/dist/registry/containers.json +2003 -0
- package/dist/registry/design-tokens.json +577 -0
- package/dist/registry/docs/boilerplate.json +55 -0
- package/dist/registry/docs/dropins-all.json +97 -0
- package/dist/registry/docs/dropins-b2b.json +607 -0
- package/dist/registry/docs/dropins-cart.json +163 -0
- package/dist/registry/docs/dropins-checkout.json +193 -0
- package/dist/registry/docs/dropins-order.json +139 -0
- package/dist/registry/docs/dropins-payment-services.json +73 -0
- package/dist/registry/docs/dropins-personalization.json +67 -0
- package/dist/registry/docs/dropins-product-details.json +139 -0
- package/dist/registry/docs/dropins-product-discovery.json +85 -0
- package/dist/registry/docs/dropins-recommendations.json +67 -0
- package/dist/registry/docs/dropins-user-account.json +121 -0
- package/dist/registry/docs/dropins-user-auth.json +103 -0
- package/dist/registry/docs/dropins-wishlist.json +85 -0
- package/dist/registry/docs/get-started.json +85 -0
- package/dist/registry/docs/how-tos.json +19 -0
- package/dist/registry/docs/index.json +139 -0
- package/dist/registry/docs/licensing.json +19 -0
- package/dist/registry/docs/merchants.json +523 -0
- package/dist/registry/docs/resources.json +13 -0
- package/dist/registry/docs/sdk.json +139 -0
- package/dist/registry/docs/setup.json +145 -0
- package/dist/registry/docs/troubleshooting.json +19 -0
- package/dist/registry/events.json +2200 -0
- package/dist/registry/examples/index.json +19 -0
- package/dist/registry/examples/storefront-checkout.json +377 -0
- package/dist/registry/examples/storefront-quote-management.json +49 -0
- package/dist/registry/extensions.json +272 -0
- package/dist/registry/graphql.json +3469 -0
- package/dist/registry/i18n.json +1873 -0
- package/dist/registry/models.json +1001 -0
- package/dist/registry/sdk.json +2357 -0
- package/dist/registry/slots.json +2270 -0
- package/dist/registry/tools-components.json +595 -0
- package/dist/resources/guides.d.ts +7 -0
- package/dist/resources/guides.js +625 -0
- package/dist/resources/handlers.d.ts +31 -0
- package/dist/resources/handlers.js +322 -0
- package/package.json +47 -0
|
@@ -0,0 +1,1149 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { formatSuccessResponse, formatExceptionResponse, } from "../common/response-handling.js";
|
|
5
|
+
import { projectDirGuard, readBlockFile, readInitializerFile, initializerCandidates, INITIALIZER_ALIASES, } from "../common/project-reader.js";
|
|
6
|
+
const KNOWN_HTML_ATTRIBUTES = new Set([
|
|
7
|
+
"id",
|
|
8
|
+
"class",
|
|
9
|
+
"className",
|
|
10
|
+
"style",
|
|
11
|
+
"title",
|
|
12
|
+
"lang",
|
|
13
|
+
"dir",
|
|
14
|
+
"hidden",
|
|
15
|
+
"tabIndex",
|
|
16
|
+
"tabindex",
|
|
17
|
+
"draggable",
|
|
18
|
+
"spellCheck",
|
|
19
|
+
"spellcheck",
|
|
20
|
+
"contentEditable",
|
|
21
|
+
"contenteditable",
|
|
22
|
+
"translate",
|
|
23
|
+
"accessKey",
|
|
24
|
+
"accesskey",
|
|
25
|
+
"slot",
|
|
26
|
+
"part",
|
|
27
|
+
"exportparts",
|
|
28
|
+
"is",
|
|
29
|
+
"itemID",
|
|
30
|
+
"itemid",
|
|
31
|
+
"itemProp",
|
|
32
|
+
"itemprop",
|
|
33
|
+
"itemRef",
|
|
34
|
+
"itemref",
|
|
35
|
+
"itemScope",
|
|
36
|
+
"itemscope",
|
|
37
|
+
"itemType",
|
|
38
|
+
"itemtype",
|
|
39
|
+
"nonce",
|
|
40
|
+
"inputMode",
|
|
41
|
+
"inputmode",
|
|
42
|
+
"enterKeyHint",
|
|
43
|
+
"enterkeyhint",
|
|
44
|
+
"autoCapitalize",
|
|
45
|
+
"autocapitalize",
|
|
46
|
+
"autoCorrect",
|
|
47
|
+
"autocorrect",
|
|
48
|
+
"autoSave",
|
|
49
|
+
"autosave",
|
|
50
|
+
"color",
|
|
51
|
+
"results",
|
|
52
|
+
"security",
|
|
53
|
+
"unselectable",
|
|
54
|
+
"name",
|
|
55
|
+
"role",
|
|
56
|
+
"popover",
|
|
57
|
+
"popoverTarget",
|
|
58
|
+
"popoverTargetAction",
|
|
59
|
+
"inert",
|
|
60
|
+
"autofocus",
|
|
61
|
+
"form",
|
|
62
|
+
"loading",
|
|
63
|
+
"srcSet",
|
|
64
|
+
"srcset",
|
|
65
|
+
"alt",
|
|
66
|
+
"src",
|
|
67
|
+
"href",
|
|
68
|
+
"target",
|
|
69
|
+
"rel",
|
|
70
|
+
"width",
|
|
71
|
+
"height",
|
|
72
|
+
"placeholder",
|
|
73
|
+
"value",
|
|
74
|
+
"checked",
|
|
75
|
+
"disabled",
|
|
76
|
+
"readOnly",
|
|
77
|
+
"readonly",
|
|
78
|
+
"required",
|
|
79
|
+
"min",
|
|
80
|
+
"max",
|
|
81
|
+
"step",
|
|
82
|
+
"pattern",
|
|
83
|
+
"maxLength",
|
|
84
|
+
"maxlength",
|
|
85
|
+
"minLength",
|
|
86
|
+
"minlength",
|
|
87
|
+
"type",
|
|
88
|
+
"action",
|
|
89
|
+
"method",
|
|
90
|
+
"encType",
|
|
91
|
+
"enctype",
|
|
92
|
+
"accept",
|
|
93
|
+
"multiple",
|
|
94
|
+
"noValidate",
|
|
95
|
+
"novalidate",
|
|
96
|
+
"autoComplete",
|
|
97
|
+
"autocomplete",
|
|
98
|
+
"wrap",
|
|
99
|
+
"rows",
|
|
100
|
+
"cols",
|
|
101
|
+
"for",
|
|
102
|
+
"htmlFor",
|
|
103
|
+
"open",
|
|
104
|
+
"defer",
|
|
105
|
+
"async",
|
|
106
|
+
"crossOrigin",
|
|
107
|
+
"crossorigin",
|
|
108
|
+
"integrity",
|
|
109
|
+
"media",
|
|
110
|
+
"sizes",
|
|
111
|
+
"download",
|
|
112
|
+
"ref",
|
|
113
|
+
"key",
|
|
114
|
+
]);
|
|
115
|
+
function isValidHtmlAttribute(prop) {
|
|
116
|
+
if (KNOWN_HTML_ATTRIBUTES.has(prop))
|
|
117
|
+
return true;
|
|
118
|
+
if (prop.startsWith("aria-"))
|
|
119
|
+
return true;
|
|
120
|
+
if (prop.startsWith("data-"))
|
|
121
|
+
return true;
|
|
122
|
+
if (/^on[A-Z]/.test(prop))
|
|
123
|
+
return true;
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
export const CheckBlockHealthSchema = z.object({
|
|
127
|
+
projectDir: z
|
|
128
|
+
.string()
|
|
129
|
+
.describe("Absolute path to the merchant's AEM project root (e.g. aem-boilerplate-commerce)"),
|
|
130
|
+
block: z
|
|
131
|
+
.string()
|
|
132
|
+
.optional()
|
|
133
|
+
.describe('Block directory name, e.g. "commerce-cart", "product-details". Omit to scan all blocks that use dropins.'),
|
|
134
|
+
fix: z
|
|
135
|
+
.boolean()
|
|
136
|
+
.optional()
|
|
137
|
+
.describe("When true, each finding includes a concrete fix suggestion: the closest valid replacement or a removal hint."),
|
|
138
|
+
});
|
|
139
|
+
const REGISTRY_DIR = join(import.meta.dirname ?? new URL(".", import.meta.url).pathname, "..", "registry");
|
|
140
|
+
function loadJson(filename) {
|
|
141
|
+
const filePath = join(REGISTRY_DIR, filename);
|
|
142
|
+
if (!existsSync(filePath))
|
|
143
|
+
return null;
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const BOOT_FUNCTIONS = new Set([
|
|
152
|
+
"initialize",
|
|
153
|
+
"setEndpoint",
|
|
154
|
+
"setFetchGraphQlHeader",
|
|
155
|
+
"enableLogger",
|
|
156
|
+
]);
|
|
157
|
+
function dropinFromImport(importPath) {
|
|
158
|
+
const match = importPath.match(/@dropins\/(storefront-[\w-]+)/);
|
|
159
|
+
return match?.[1] ?? null;
|
|
160
|
+
}
|
|
161
|
+
function extractDropinImports(source) {
|
|
162
|
+
const results = [];
|
|
163
|
+
const importRegex = /import\s+(?:(\w+)|(\*\s+as\s+\w+)|\{([^}]+)\})\s+from\s+['"]([^'"]+)['"]/g;
|
|
164
|
+
let match;
|
|
165
|
+
while ((match = importRegex.exec(source)) !== null) {
|
|
166
|
+
const importPath = match[4];
|
|
167
|
+
const dropin = dropinFromImport(importPath);
|
|
168
|
+
if (!dropin)
|
|
169
|
+
continue;
|
|
170
|
+
const names = [];
|
|
171
|
+
if (match[1])
|
|
172
|
+
names.push(match[1]);
|
|
173
|
+
if (match[2])
|
|
174
|
+
names.push(match[2].replace(/\*\s+as\s+/, ""));
|
|
175
|
+
if (match[3]) {
|
|
176
|
+
match[3].split(",").forEach((n) => {
|
|
177
|
+
const cleaned = n.trim().split(/\s+as\s+/);
|
|
178
|
+
if (cleaned[0])
|
|
179
|
+
names.push(cleaned[cleaned.length - 1].trim());
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
results.push({ dropin, importPath, importedNames: names });
|
|
183
|
+
}
|
|
184
|
+
return results;
|
|
185
|
+
}
|
|
186
|
+
function extractBlockUsage(sources) {
|
|
187
|
+
const combined = sources.join("\n");
|
|
188
|
+
const imports = extractDropinImports(combined);
|
|
189
|
+
const detectedDropins = [...new Set(imports.map((i) => i.dropin))];
|
|
190
|
+
const containers = [];
|
|
191
|
+
const containerLocalNames = new Map();
|
|
192
|
+
for (const imp of imports) {
|
|
193
|
+
if (imp.importPath.includes("/containers/")) {
|
|
194
|
+
const containerName = imp.importPath
|
|
195
|
+
.split("/containers/")
|
|
196
|
+
.pop()
|
|
197
|
+
?.replace(".js", "");
|
|
198
|
+
if (containerName) {
|
|
199
|
+
containers.push({ dropin: imp.dropin, name: containerName });
|
|
200
|
+
for (const localName of imp.importedNames) {
|
|
201
|
+
containerLocalNames.set(localName, {
|
|
202
|
+
dropin: imp.dropin,
|
|
203
|
+
name: containerName,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const containerProps = [];
|
|
210
|
+
const slots = [];
|
|
211
|
+
for (const [localName, { dropin, name }] of containerLocalNames) {
|
|
212
|
+
const renderRegex = new RegExp(`(?<![\\w$])render\\(\\s*${localName}\\s*,\\s*\\{`, "g");
|
|
213
|
+
let renderMatch;
|
|
214
|
+
while ((renderMatch = renderRegex.exec(combined)) !== null) {
|
|
215
|
+
const startIdx = renderMatch.index + renderMatch[0].length;
|
|
216
|
+
const propsBlock = extractBalancedBlock(combined, startIdx - 1);
|
|
217
|
+
if (propsBlock) {
|
|
218
|
+
extractPropsAndSlots(propsBlock, dropin, name, containerProps, slots);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const events = [];
|
|
223
|
+
const eventRegex = /events\.\s*(?:on|emit)\s*\(\s*['"]([^'"]+)['"]/g;
|
|
224
|
+
let eventMatch;
|
|
225
|
+
while ((eventMatch = eventRegex.exec(combined)) !== null) {
|
|
226
|
+
const eventName = eventMatch[1];
|
|
227
|
+
const eventDropin = guessDropinFromEventName(eventName, detectedDropins);
|
|
228
|
+
events.push({ dropin: eventDropin, event: eventName });
|
|
229
|
+
}
|
|
230
|
+
const apiFunctions = [];
|
|
231
|
+
const apiNamespaces = new Map();
|
|
232
|
+
const starImportRegex = /import\s+\*\s+as\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g;
|
|
233
|
+
let starMatch;
|
|
234
|
+
while ((starMatch = starImportRegex.exec(combined)) !== null) {
|
|
235
|
+
const ns = starMatch[1];
|
|
236
|
+
const d = dropinFromImport(starMatch[2]);
|
|
237
|
+
if (d &&
|
|
238
|
+
(starMatch[2].endsWith("/api.js") || starMatch[2].includes("/api"))) {
|
|
239
|
+
apiNamespaces.set(ns, d);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const imp of imports) {
|
|
243
|
+
if (imp.importPath.endsWith("/api.js") || imp.importPath.includes("/api")) {
|
|
244
|
+
for (const name of imp.importedNames) {
|
|
245
|
+
if (BOOT_FUNCTIONS.has(name))
|
|
246
|
+
continue;
|
|
247
|
+
if (apiNamespaces.has(name))
|
|
248
|
+
continue;
|
|
249
|
+
apiFunctions.push({ dropin: imp.dropin, fn: name });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const [ns, dropin] of apiNamespaces) {
|
|
254
|
+
const nsCallRegex = new RegExp(`(?<![\\w$])${ns}\\.(\\w+)\\s*\\(`, "g");
|
|
255
|
+
let nsMatch;
|
|
256
|
+
while ((nsMatch = nsCallRegex.exec(combined)) !== null) {
|
|
257
|
+
if (BOOT_FUNCTIONS.has(nsMatch[1]))
|
|
258
|
+
continue;
|
|
259
|
+
apiFunctions.push({ dropin, fn: nsMatch[1] });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const toolsComponentProps = [];
|
|
263
|
+
const toolsImportRegex = /import\s+\{([^}]+)\}\s+from\s+['"]@dropins\/tools\/components(?:\.js)?['"]/g;
|
|
264
|
+
let toolsMatch;
|
|
265
|
+
const toolsLocalNames = new Map();
|
|
266
|
+
while ((toolsMatch = toolsImportRegex.exec(combined)) !== null) {
|
|
267
|
+
const names = toolsMatch[1].split(",");
|
|
268
|
+
for (const raw of names) {
|
|
269
|
+
const parts = raw.trim().split(/\s+as\s+/);
|
|
270
|
+
const originalName = parts[0].trim();
|
|
271
|
+
const localName = parts[parts.length - 1].trim();
|
|
272
|
+
if (originalName === "provider")
|
|
273
|
+
continue;
|
|
274
|
+
toolsLocalNames.set(localName, originalName);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const [localName, componentName] of toolsLocalNames) {
|
|
278
|
+
const renderRegex = new RegExp(`(?<![\\w$])render\\(\\s*${localName}\\s*,\\s*\\{`, "g");
|
|
279
|
+
let renderHit;
|
|
280
|
+
while ((renderHit = renderRegex.exec(combined)) !== null) {
|
|
281
|
+
const startIdx = renderHit.index + renderHit[0].length;
|
|
282
|
+
const propsBlock = extractBalancedBlock(combined, startIdx - 1);
|
|
283
|
+
if (propsBlock) {
|
|
284
|
+
const propNames = extractTopLevelKeys(propsBlock);
|
|
285
|
+
for (const prop of propNames) {
|
|
286
|
+
toolsComponentProps.push({ component: componentName, prop });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
detectedDropins,
|
|
293
|
+
containers,
|
|
294
|
+
containerProps,
|
|
295
|
+
slots,
|
|
296
|
+
events,
|
|
297
|
+
apiFunctions: dedupeBy(apiFunctions, (a) => `${a.dropin}:${a.fn}`),
|
|
298
|
+
toolsComponentProps: dedupeBy(toolsComponentProps, (a) => `${a.component}:${a.prop}`),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function extractBalancedBlock(source, startIdx) {
|
|
302
|
+
if (source[startIdx] !== "{")
|
|
303
|
+
return null;
|
|
304
|
+
let depth = 0;
|
|
305
|
+
for (let i = startIdx; i < source.length; i++) {
|
|
306
|
+
if (source[i] === "{")
|
|
307
|
+
depth++;
|
|
308
|
+
else if (source[i] === "}") {
|
|
309
|
+
depth--;
|
|
310
|
+
if (depth === 0)
|
|
311
|
+
return source.slice(startIdx + 1, i);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
function extractPropsAndSlots(propsBlock, dropin, container, containerProps, slots) {
|
|
317
|
+
const topKeys = extractTopLevelKeys(propsBlock);
|
|
318
|
+
for (const key of topKeys) {
|
|
319
|
+
if (key === "slots") {
|
|
320
|
+
const slotsRegex = /\bslots\s*:\s*\{/;
|
|
321
|
+
const slotsMatch = slotsRegex.exec(propsBlock);
|
|
322
|
+
if (slotsMatch) {
|
|
323
|
+
const slotsStart = slotsMatch.index + slotsMatch[0].length - 1;
|
|
324
|
+
const slotsContent = extractBalancedBlock(propsBlock, slotsStart);
|
|
325
|
+
if (slotsContent) {
|
|
326
|
+
const slotNames = extractTopLevelKeys(slotsContent);
|
|
327
|
+
for (const slot of slotNames) {
|
|
328
|
+
slots.push({ dropin, container, slot });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
containerProps.push({ dropin, container, prop: key });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function extractTopLevelKeys(body) {
|
|
339
|
+
const keys = [];
|
|
340
|
+
let depth = 0;
|
|
341
|
+
let expectKey = true;
|
|
342
|
+
let i = 0;
|
|
343
|
+
while (i < body.length) {
|
|
344
|
+
const ch = body[i];
|
|
345
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
346
|
+
i++;
|
|
347
|
+
while (i < body.length && body[i] !== ch) {
|
|
348
|
+
if (body[i] === "\\")
|
|
349
|
+
i++;
|
|
350
|
+
i++;
|
|
351
|
+
}
|
|
352
|
+
i++;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (ch === "{" || ch === "(" || ch === "[") {
|
|
356
|
+
depth++;
|
|
357
|
+
i++;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (ch === "}" || ch === ")" || ch === "]") {
|
|
361
|
+
depth--;
|
|
362
|
+
i++;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (depth === 0) {
|
|
366
|
+
if (ch === ",") {
|
|
367
|
+
expectKey = true;
|
|
368
|
+
i++;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (expectKey) {
|
|
372
|
+
const remaining = body.slice(i);
|
|
373
|
+
const keyMatch = remaining.match(/^([\w-]+)\s*:/);
|
|
374
|
+
if (keyMatch) {
|
|
375
|
+
keys.push(keyMatch[1]);
|
|
376
|
+
i += keyMatch[0].length;
|
|
377
|
+
expectKey = false;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const isWordStart = i === 0 || (!/\w/.test(body[i - 1]) && body[i - 1] !== ".");
|
|
381
|
+
if (isWordStart) {
|
|
382
|
+
const shorthandMatch = remaining.match(/^([a-zA-Z_$][\w]*)(?=\s*[,}])/);
|
|
383
|
+
if (shorthandMatch) {
|
|
384
|
+
keys.push(shorthandMatch[1]);
|
|
385
|
+
i += shorthandMatch[1].length;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
i++;
|
|
392
|
+
}
|
|
393
|
+
return keys;
|
|
394
|
+
}
|
|
395
|
+
function guessDropinFromEventName(eventName, detectedDropins) {
|
|
396
|
+
const prefix = eventName.split("/")[0];
|
|
397
|
+
const mapping = {
|
|
398
|
+
cart: "storefront-cart",
|
|
399
|
+
checkout: "storefront-checkout",
|
|
400
|
+
order: "storefront-order",
|
|
401
|
+
auth: "storefront-auth",
|
|
402
|
+
account: "storefront-account",
|
|
403
|
+
pdp: "storefront-pdp",
|
|
404
|
+
search: "storefront-product-discovery",
|
|
405
|
+
wishlist: "storefront-wishlist",
|
|
406
|
+
recommendations: "storefront-recommendations",
|
|
407
|
+
payment: "storefront-payment-services",
|
|
408
|
+
personalization: "storefront-personalization",
|
|
409
|
+
};
|
|
410
|
+
const mapped = mapping[prefix];
|
|
411
|
+
if (mapped)
|
|
412
|
+
return mapped;
|
|
413
|
+
return "unknown";
|
|
414
|
+
}
|
|
415
|
+
function dedupeBy(arr, key) {
|
|
416
|
+
const seen = new Set();
|
|
417
|
+
return arr.filter((item) => {
|
|
418
|
+
const k = key(item);
|
|
419
|
+
if (seen.has(k))
|
|
420
|
+
return false;
|
|
421
|
+
seen.add(k);
|
|
422
|
+
return true;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function levenshtein(a, b) {
|
|
426
|
+
const m = a.length;
|
|
427
|
+
const n = b.length;
|
|
428
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
|
|
429
|
+
for (let i = 1; i <= m; i++) {
|
|
430
|
+
for (let j = 1; j <= n; j++) {
|
|
431
|
+
dp[i][j] =
|
|
432
|
+
a[i - 1] === b[j - 1]
|
|
433
|
+
? dp[i - 1][j - 1]
|
|
434
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return dp[m][n];
|
|
438
|
+
}
|
|
439
|
+
function findClosest(name, candidates, threshold = 3) {
|
|
440
|
+
if (candidates.includes(name))
|
|
441
|
+
return null;
|
|
442
|
+
let best = null;
|
|
443
|
+
let bestDist = Infinity;
|
|
444
|
+
for (const candidate of candidates) {
|
|
445
|
+
const dist = levenshtein(name, candidate);
|
|
446
|
+
if (dist < bestDist) {
|
|
447
|
+
bestDist = dist;
|
|
448
|
+
best = candidate;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return bestDist <= threshold ? best : null;
|
|
452
|
+
}
|
|
453
|
+
function findClosestDropin(name, knownDropins) {
|
|
454
|
+
return findClosest(name, knownDropins, 3);
|
|
455
|
+
}
|
|
456
|
+
function augmentFindingsWithFixes(findings, containerReg, slotReg, eventCatalog, apiReg, toolsComponentsReg) {
|
|
457
|
+
const toolsComponentMap = buildToolsComponentMap(toolsComponentsReg);
|
|
458
|
+
return findings.map((f) => {
|
|
459
|
+
let fix;
|
|
460
|
+
switch (f.category) {
|
|
461
|
+
case "dropin": {
|
|
462
|
+
const knownDropins = containerReg
|
|
463
|
+
? Object.keys(containerReg.dropins)
|
|
464
|
+
: [];
|
|
465
|
+
const suggestion = findClosest(f.what, knownDropins, 3);
|
|
466
|
+
fix = suggestion
|
|
467
|
+
? {
|
|
468
|
+
action: "rename",
|
|
469
|
+
suggestion,
|
|
470
|
+
hint: `Update all imports: replace "@dropins/${f.what}/..." with "@dropins/${suggestion}/..."`,
|
|
471
|
+
}
|
|
472
|
+
: {
|
|
473
|
+
action: "remove",
|
|
474
|
+
suggestion: null,
|
|
475
|
+
hint: `Remove or replace all imports from "@dropins/${f.what}" — package is not recognised.`,
|
|
476
|
+
};
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
case "container": {
|
|
480
|
+
const dropinData = containerReg?.dropins[f.dropin];
|
|
481
|
+
const candidates = dropinData?.containers.map((c) => c.name) ?? [];
|
|
482
|
+
const suggestion = findClosest(f.what, candidates, 4);
|
|
483
|
+
fix = suggestion
|
|
484
|
+
? {
|
|
485
|
+
action: "rename",
|
|
486
|
+
suggestion,
|
|
487
|
+
hint: `Update import: replace "@dropins/${f.dropin}/containers/${f.what}.js" with "@dropins/${f.dropin}/containers/${suggestion}.js" and rename the local identifier.`,
|
|
488
|
+
}
|
|
489
|
+
: {
|
|
490
|
+
action: "remove",
|
|
491
|
+
suggestion: null,
|
|
492
|
+
hint: `Remove the import for "${f.what}" and its render call — it no longer exists in ${f.dropin}.`,
|
|
493
|
+
};
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
case "prop": {
|
|
497
|
+
const [container, prop] = f.what.split(".");
|
|
498
|
+
let candidates;
|
|
499
|
+
if (f.dropin === "tools/components") {
|
|
500
|
+
const schema = toolsComponentMap[container];
|
|
501
|
+
candidates = schema ? schema.props : [];
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
const dropinData = containerReg?.dropins[f.dropin];
|
|
505
|
+
const containerEntry = dropinData?.containers.find((c) => c.name === container);
|
|
506
|
+
candidates = containerEntry ? Object.keys(containerEntry.props) : [];
|
|
507
|
+
}
|
|
508
|
+
const suggestion = findClosest(prop, candidates, 4);
|
|
509
|
+
if (suggestion) {
|
|
510
|
+
fix = {
|
|
511
|
+
action: "rename",
|
|
512
|
+
suggestion,
|
|
513
|
+
hint: `In the render call for ${container}: rename prop "${prop}" to "${suggestion}".`,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
else if (f.severity === "info" &&
|
|
517
|
+
f.detail.includes("but exists on")) {
|
|
518
|
+
const rightContainer = f.detail.match(/exists on "(\w+)"/)?.[1];
|
|
519
|
+
fix = {
|
|
520
|
+
action: "remove",
|
|
521
|
+
suggestion: null,
|
|
522
|
+
hint: `Move prop "${prop}" from the ${container} render call to ${rightContainer ?? "the correct container"} — it does not belong to ${container}.`,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
fix = {
|
|
527
|
+
action: "remove",
|
|
528
|
+
suggestion: null,
|
|
529
|
+
hint: `Remove prop "${prop}" from the render call for ${container} — it is not in the current registry.`,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
case "slot": {
|
|
535
|
+
const [container, slot] = f.what.split(".");
|
|
536
|
+
const dropinSlotData = slotReg?.dropins[f.dropin];
|
|
537
|
+
const containerSlots = dropinSlotData?.containers[container];
|
|
538
|
+
const candidates = containerSlots?.slots.map((s) => s.name) ?? [];
|
|
539
|
+
const suggestion = findClosest(slot, candidates, 4);
|
|
540
|
+
fix = suggestion
|
|
541
|
+
? {
|
|
542
|
+
action: "rename",
|
|
543
|
+
suggestion,
|
|
544
|
+
hint: `In the slots object for ${container}: rename "${slot}" to "${suggestion}".`,
|
|
545
|
+
}
|
|
546
|
+
: {
|
|
547
|
+
action: "remove",
|
|
548
|
+
suggestion: null,
|
|
549
|
+
hint: `Remove slot "${slot}" from the slots object for ${container} — it no longer exists.`,
|
|
550
|
+
};
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
case "event": {
|
|
554
|
+
const allEvents = eventCatalog?.events.map((e) => e.name) ?? [];
|
|
555
|
+
const suggestion = findClosest(f.what, allEvents, 5);
|
|
556
|
+
fix = suggestion
|
|
557
|
+
? {
|
|
558
|
+
action: "rename",
|
|
559
|
+
suggestion,
|
|
560
|
+
hint: `Replace '${f.what}' with '${suggestion}' in all events.on/events.emit calls.`,
|
|
561
|
+
}
|
|
562
|
+
: {
|
|
563
|
+
action: "remove",
|
|
564
|
+
suggestion: null,
|
|
565
|
+
hint: `Remove or comment out the handler for '${f.what}' — it is not in the current event registry.`,
|
|
566
|
+
};
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
case "api-function": {
|
|
570
|
+
const dropinData = apiReg?.dropins[f.dropin];
|
|
571
|
+
const candidates = dropinData?.functions.map((fn) => fn.name) ?? [];
|
|
572
|
+
const suggestion = findClosest(f.what, candidates, 4);
|
|
573
|
+
fix = suggestion
|
|
574
|
+
? {
|
|
575
|
+
action: "rename",
|
|
576
|
+
suggestion,
|
|
577
|
+
hint: `Replace ${f.what}() with ${suggestion}() — update the import and all call sites.`,
|
|
578
|
+
}
|
|
579
|
+
: {
|
|
580
|
+
action: "remove",
|
|
581
|
+
suggestion: null,
|
|
582
|
+
hint: `Remove ${f.what} from the import statement and all its call sites — it is not in the current ${f.dropin} API.`,
|
|
583
|
+
};
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
case "initializer": {
|
|
587
|
+
if (f.what.endsWith(":import")) {
|
|
588
|
+
fix = {
|
|
589
|
+
action: "rename",
|
|
590
|
+
suggestion: `@dropins/${f.dropin}/api.js`,
|
|
591
|
+
hint: `Update the import to: import { initialize, setEndpoint } from '@dropins/${f.dropin}/api.js';`,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
else if (f.what.endsWith(":initialize")) {
|
|
595
|
+
fix = {
|
|
596
|
+
action: "rename",
|
|
597
|
+
suggestion: "initializers.mountImmediately(initialize, { ... })",
|
|
598
|
+
hint: `Add: import { initialize } from '@dropins/${f.dropin}/api.js'; then call initializers.mountImmediately(initialize, { langDefinitions });`,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
else if (f.what.endsWith(":setEndpoint")) {
|
|
602
|
+
fix = {
|
|
603
|
+
action: "rename",
|
|
604
|
+
suggestion: "setEndpoint(FETCH_GRAPHQL)",
|
|
605
|
+
hint: `Add: import { setEndpoint } from '@dropins/${f.dropin}/api.js'; then call setEndpoint(CORE_FETCH_GRAPHQL) or setEndpoint(CS_FETCH_GRAPHQL).`,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
else if (f.detail.includes("No initializer file found")) {
|
|
609
|
+
const initName = INITIALIZER_ALIASES[f.dropin] ??
|
|
610
|
+
f.dropin.replace("storefront-", "");
|
|
611
|
+
fix = {
|
|
612
|
+
action: "rename",
|
|
613
|
+
suggestion: `scripts/initializers/${initName}.js`,
|
|
614
|
+
hint: `Create scripts/initializers/${initName}.js with: import { initialize, setEndpoint } from '@dropins/${f.dropin}/api.js'; setEndpoint(CORE_FETCH_GRAPHQL); initializers.mountImmediately(initialize, {});`,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
else if (f.detail.includes("no block imports")) {
|
|
618
|
+
fix = {
|
|
619
|
+
action: "remove",
|
|
620
|
+
suggestion: null,
|
|
621
|
+
hint: `Delete ${f.what} if the dropin is no longer used, or add a block that imports from ${f.dropin}.`,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
else if (f.detail.includes("does not import from any")) {
|
|
625
|
+
fix = {
|
|
626
|
+
action: "rename",
|
|
627
|
+
suggestion: `@dropins/${f.dropin}/api.js`,
|
|
628
|
+
hint: `Add: import { initialize, setEndpoint } from '@dropins/${f.dropin}/api.js';`,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return fix ? { ...f, fix } : f;
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
function loadRegistries() {
|
|
638
|
+
return {
|
|
639
|
+
containerReg: loadJson("containers.json"),
|
|
640
|
+
slotReg: loadJson("slots.json"),
|
|
641
|
+
eventCatalog: loadJson("events.json"),
|
|
642
|
+
apiReg: loadJson("api-functions.json"),
|
|
643
|
+
toolsComponentsReg: loadJson("tools-components.json"),
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
function buildToolsComponentMap(reg) {
|
|
647
|
+
const map = {};
|
|
648
|
+
if (!reg)
|
|
649
|
+
return map;
|
|
650
|
+
for (const c of reg.components) {
|
|
651
|
+
map[c.name] = {
|
|
652
|
+
props: c.props,
|
|
653
|
+
extendsHTMLAttributes: c.extendsHTMLAttributes,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
return map;
|
|
657
|
+
}
|
|
658
|
+
function compareAgainstRegistry(usage, regs) {
|
|
659
|
+
const findings = [];
|
|
660
|
+
const { containerReg, slotReg, eventCatalog, apiReg, toolsComponentsReg } = regs;
|
|
661
|
+
const toolsComponentMap = buildToolsComponentMap(toolsComponentsReg);
|
|
662
|
+
const knownDropins = containerReg ? Object.keys(containerReg.dropins) : [];
|
|
663
|
+
if (containerReg) {
|
|
664
|
+
const checkedDropins = new Set();
|
|
665
|
+
for (const { dropin } of usage.detectedDropins.map((d) => ({
|
|
666
|
+
dropin: d,
|
|
667
|
+
}))) {
|
|
668
|
+
if (checkedDropins.has(dropin))
|
|
669
|
+
continue;
|
|
670
|
+
checkedDropins.add(dropin);
|
|
671
|
+
if (!(dropin in containerReg.dropins)) {
|
|
672
|
+
const suggestion = findClosestDropin(dropin, knownDropins);
|
|
673
|
+
findings.push({
|
|
674
|
+
severity: "error",
|
|
675
|
+
category: "dropin",
|
|
676
|
+
dropin,
|
|
677
|
+
what: dropin,
|
|
678
|
+
detail: suggestion
|
|
679
|
+
? `Dropin "@dropins/${dropin}" is not recognised — did you mean "@dropins/${suggestion}"? Check the import path for a typo.`
|
|
680
|
+
: `Dropin "@dropins/${dropin}" is not recognised. Verify the package name matches an installed @dropins package.`,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (containerReg) {
|
|
686
|
+
for (const { dropin, name } of usage.containers) {
|
|
687
|
+
const dropinData = containerReg.dropins[dropin];
|
|
688
|
+
if (!dropinData)
|
|
689
|
+
continue;
|
|
690
|
+
const exists = dropinData.containers.some((c) => c.name === name);
|
|
691
|
+
if (!exists) {
|
|
692
|
+
findings.push({
|
|
693
|
+
severity: "error",
|
|
694
|
+
category: "container",
|
|
695
|
+
dropin,
|
|
696
|
+
what: name,
|
|
697
|
+
detail: `Container "${name}" is not found in the current ${dropin} registry. It may have been removed or renamed.`,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
for (const { dropin, container, prop } of usage.containerProps) {
|
|
702
|
+
const dropinData = containerReg.dropins[dropin];
|
|
703
|
+
if (!dropinData)
|
|
704
|
+
continue;
|
|
705
|
+
const containerEntry = dropinData.containers.find((c) => c.name === container);
|
|
706
|
+
if (!containerEntry)
|
|
707
|
+
continue;
|
|
708
|
+
const propExists = Object.keys(containerEntry.props).includes(prop) || prop === "slots";
|
|
709
|
+
if (!propExists) {
|
|
710
|
+
if (containerEntry.extendsHTMLAttributes && isValidHtmlAttribute(prop))
|
|
711
|
+
continue;
|
|
712
|
+
const dropinContainers = containerReg.dropins[dropin]?.containers ?? [];
|
|
713
|
+
const otherContainer = dropinContainers.find((c) => c.name !== container && Object.keys(c.props).includes(prop));
|
|
714
|
+
if (otherContainer) {
|
|
715
|
+
findings.push({
|
|
716
|
+
severity: "info",
|
|
717
|
+
category: "prop",
|
|
718
|
+
dropin,
|
|
719
|
+
what: `${container}.${prop}`,
|
|
720
|
+
detail: `Prop "${prop}" is not on container "${container}" but exists on "${otherContainer.name}" in ${dropin}. It may be passed to the wrong container.`,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
const htmlNote = containerEntry.extendsHTMLAttributes
|
|
725
|
+
? ` "${container}" extends HTMLAttributes but "${prop}" is not a recognised HTML attribute — it will be silently ignored at runtime.`
|
|
726
|
+
: "";
|
|
727
|
+
findings.push({
|
|
728
|
+
severity: "warning",
|
|
729
|
+
category: "prop",
|
|
730
|
+
dropin,
|
|
731
|
+
what: `${container}.${prop}`,
|
|
732
|
+
detail: `Prop "${prop}" on container "${container}" is not found in the current ${dropin} registry. It may have been removed, renamed, or is undocumented.${htmlNote}`,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
for (const { component, prop } of usage.toolsComponentProps) {
|
|
739
|
+
const schema = toolsComponentMap[component];
|
|
740
|
+
if (!schema)
|
|
741
|
+
continue;
|
|
742
|
+
if (schema.props.includes(prop))
|
|
743
|
+
continue;
|
|
744
|
+
if (schema.extendsHTMLAttributes && isValidHtmlAttribute(prop))
|
|
745
|
+
continue;
|
|
746
|
+
const htmlNote = schema.extendsHTMLAttributes
|
|
747
|
+
? ` "${component}" extends HTMLAttributes but "${prop}" is not a recognised HTML attribute — it will be silently ignored at runtime.`
|
|
748
|
+
: "";
|
|
749
|
+
findings.push({
|
|
750
|
+
severity: "warning",
|
|
751
|
+
category: "prop",
|
|
752
|
+
dropin: "tools/components",
|
|
753
|
+
what: `${component}.${prop}`,
|
|
754
|
+
detail: `Prop "${prop}" on UI component "${component}" (@dropins/tools/components.js) is not a known prop.${htmlNote}`,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
if (slotReg) {
|
|
758
|
+
for (const { dropin, container, slot } of usage.slots) {
|
|
759
|
+
const dropinData = slotReg.dropins[dropin];
|
|
760
|
+
if (!dropinData)
|
|
761
|
+
continue;
|
|
762
|
+
const containerSlots = dropinData.containers[container];
|
|
763
|
+
if (!containerSlots)
|
|
764
|
+
continue;
|
|
765
|
+
const slotExists = containerSlots.slots.some((s) => s.name === slot);
|
|
766
|
+
if (!slotExists) {
|
|
767
|
+
findings.push({
|
|
768
|
+
severity: "error",
|
|
769
|
+
category: "slot",
|
|
770
|
+
dropin,
|
|
771
|
+
what: `${container}.${slot}`,
|
|
772
|
+
detail: `Slot "${slot}" on container "${container}" is not found in the current ${dropin} registry. It may have been removed or renamed.`,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (eventCatalog) {
|
|
778
|
+
const registryEventNames = new Set(eventCatalog.events.map((e) => e.name));
|
|
779
|
+
for (const { dropin, event } of usage.events) {
|
|
780
|
+
if (!registryEventNames.has(event)) {
|
|
781
|
+
findings.push({
|
|
782
|
+
severity: "error",
|
|
783
|
+
category: "event",
|
|
784
|
+
dropin,
|
|
785
|
+
what: event,
|
|
786
|
+
detail: `Event "${event}" is not found in the current registry. It may have been removed or renamed.`,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (apiReg) {
|
|
792
|
+
for (const { dropin, fn } of usage.apiFunctions) {
|
|
793
|
+
const dropinData = apiReg.dropins[dropin];
|
|
794
|
+
if (!dropinData)
|
|
795
|
+
continue;
|
|
796
|
+
const fnExists = dropinData.functions.some((f) => f.name === fn);
|
|
797
|
+
if (!fnExists) {
|
|
798
|
+
const isShared = fn in (apiReg.sharedFunctions ?? {});
|
|
799
|
+
if (!isShared) {
|
|
800
|
+
findings.push({
|
|
801
|
+
severity: "error",
|
|
802
|
+
category: "api-function",
|
|
803
|
+
dropin,
|
|
804
|
+
what: fn,
|
|
805
|
+
detail: `API function "${fn}" is not found in the current ${dropin} registry. It may have been removed or renamed.`,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
return findings;
|
|
812
|
+
}
|
|
813
|
+
function collectBlockSources(projectDir, blockName, mainSource, detectedDropins) {
|
|
814
|
+
const sources = [mainSource];
|
|
815
|
+
const fileList = [`blocks/${blockName}/${blockName}.js`];
|
|
816
|
+
const blockDir = join(projectDir, "blocks", blockName);
|
|
817
|
+
if (existsSync(blockDir)) {
|
|
818
|
+
try {
|
|
819
|
+
for (const file of readdirSync(blockDir)) {
|
|
820
|
+
if (file.endsWith(".js") &&
|
|
821
|
+
file !== `${blockName}.js` &&
|
|
822
|
+
!file.endsWith(".test.js")) {
|
|
823
|
+
const content = readFileSync(join(blockDir, file), "utf8");
|
|
824
|
+
sources.push(content);
|
|
825
|
+
fileList.push(`blocks/${blockName}/${file}`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const initDir = join(projectDir, "scripts", "initializers");
|
|
833
|
+
for (const dropin of detectedDropins) {
|
|
834
|
+
const initContent = readInitializerFile(projectDir, dropin);
|
|
835
|
+
if (initContent) {
|
|
836
|
+
fileList.push(resolveInitializerPath(initDir, dropin));
|
|
837
|
+
sources.push(initContent);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return { sources, fileList };
|
|
841
|
+
}
|
|
842
|
+
function resolveInitializerPath(initDir, dropinName) {
|
|
843
|
+
for (const candidate of initializerCandidates(dropinName)) {
|
|
844
|
+
if (existsSync(join(initDir, candidate))) {
|
|
845
|
+
return `scripts/initializers/${candidate}`;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const shortName = dropinName.replace("storefront-", "");
|
|
849
|
+
return `scripts/initializers/${shortName}.js`;
|
|
850
|
+
}
|
|
851
|
+
function checkInitializerHealth(projectDir, usedDropins, regs, fix) {
|
|
852
|
+
const initDir = join(projectDir, "scripts", "initializers");
|
|
853
|
+
const reports = [];
|
|
854
|
+
const findings = [];
|
|
855
|
+
const knownDropins = regs.containerReg
|
|
856
|
+
? Object.keys(regs.containerReg.dropins)
|
|
857
|
+
: [];
|
|
858
|
+
for (const dropin of usedDropins) {
|
|
859
|
+
const shortName = dropin.replace("storefront-", "");
|
|
860
|
+
const content = readInitializerFile(projectDir, dropin);
|
|
861
|
+
if (!content) {
|
|
862
|
+
findings.push({
|
|
863
|
+
severity: "warning",
|
|
864
|
+
category: "initializer",
|
|
865
|
+
dropin,
|
|
866
|
+
what: `scripts/initializers/${shortName}.js`,
|
|
867
|
+
detail: `No initializer file found for ${dropin}. Blocks import from this dropin but it has no initializer to call setEndpoint/initialize.`,
|
|
868
|
+
});
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
const filePath = resolveInitializerPath(initDir, dropin);
|
|
872
|
+
const fileFindings = [];
|
|
873
|
+
const namedImportRegex = /import\s+\{[^}]*\}\s+from\s+['"]@dropins\/(storefront-[\w-]+)\/api(?:\.js)?['"]/g;
|
|
874
|
+
const nsImportRegex = /import\s+\*\s+as\s+(\w+)\s+from\s+['"]@dropins\/(storefront-[\w-]+)\/api(?:\.js)?['"]/g;
|
|
875
|
+
const importedDropins = [];
|
|
876
|
+
const nsAliases = [];
|
|
877
|
+
for (const m of content.matchAll(namedImportRegex)) {
|
|
878
|
+
importedDropins.push(m[1]);
|
|
879
|
+
}
|
|
880
|
+
for (const m of content.matchAll(nsImportRegex)) {
|
|
881
|
+
importedDropins.push(m[2]);
|
|
882
|
+
nsAliases.push(m[1]);
|
|
883
|
+
}
|
|
884
|
+
if (importedDropins.length === 0) {
|
|
885
|
+
fileFindings.push({
|
|
886
|
+
severity: "warning",
|
|
887
|
+
category: "initializer",
|
|
888
|
+
dropin,
|
|
889
|
+
what: filePath,
|
|
890
|
+
detail: `Initializer "${filePath}" does not import from any @dropins/*/api module. Expected an import from "@dropins/${dropin}/api.js".`,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
else if (!importedDropins.includes(dropin)) {
|
|
894
|
+
const actual = importedDropins[0];
|
|
895
|
+
fileFindings.push({
|
|
896
|
+
severity: "error",
|
|
897
|
+
category: "initializer",
|
|
898
|
+
dropin,
|
|
899
|
+
what: `${filePath}:import`,
|
|
900
|
+
detail: `Initializer "${filePath}" imports from "@dropins/${actual}/api.js" but should import from "@dropins/${dropin}/api.js".`,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
const mountImmediatelyBare = /\binitializers\s*\.\s*mountImmediately\s*\(\s*initialize\b/.test(content);
|
|
904
|
+
const mountImmediatelyNs = nsAliases.some((ns) => new RegExp(`\\binitializers\\s*\\.\\s*mountImmediately\\s*\\(\\s*${ns}\\.initialize\\b`).test(content));
|
|
905
|
+
const hasMountImmediately = mountImmediatelyBare || mountImmediatelyNs;
|
|
906
|
+
const hasRawInitialize = !hasMountImmediately && /\binitialize\s*\(/.test(content);
|
|
907
|
+
if (!hasMountImmediately && !hasRawInitialize) {
|
|
908
|
+
fileFindings.push({
|
|
909
|
+
severity: "error",
|
|
910
|
+
category: "initializer",
|
|
911
|
+
dropin,
|
|
912
|
+
what: `${filePath}:initialize`,
|
|
913
|
+
detail: `Initializer "${filePath}" does not call initialize(). The dropin will not start. Expected: initializers.mountImmediately(initialize, { ... })`,
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
else if (hasRawInitialize) {
|
|
917
|
+
fileFindings.push({
|
|
918
|
+
severity: "warning",
|
|
919
|
+
category: "initializer",
|
|
920
|
+
dropin,
|
|
921
|
+
what: `${filePath}:initialize`,
|
|
922
|
+
detail: `Initializer "${filePath}" calls initialize() directly instead of initializers.mountImmediately(initialize, { ... }). The dropin may not integrate correctly with the framework lifecycle.`,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
const hasSetEndpoint = /\bsetEndpoint\s*\(/.test(content);
|
|
926
|
+
if (!hasSetEndpoint && nsAliases.length === 0) {
|
|
927
|
+
fileFindings.push({
|
|
928
|
+
severity: "warning",
|
|
929
|
+
category: "initializer",
|
|
930
|
+
dropin,
|
|
931
|
+
what: `${filePath}:setEndpoint`,
|
|
932
|
+
detail: `Initializer "${filePath}" does not call setEndpoint(). The dropin may not reach the correct GraphQL backend.`,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
reports.push({ file: filePath, dropin, findings: fileFindings });
|
|
936
|
+
findings.push(...fileFindings);
|
|
937
|
+
}
|
|
938
|
+
const reverseAliases = new Map();
|
|
939
|
+
for (const [dropin, alias] of Object.entries(INITIALIZER_ALIASES)) {
|
|
940
|
+
reverseAliases.set(alias, dropin);
|
|
941
|
+
}
|
|
942
|
+
const claimedFiles = new Set();
|
|
943
|
+
for (const d of usedDropins) {
|
|
944
|
+
for (const c of initializerCandidates(d)) {
|
|
945
|
+
claimedFiles.add(c);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (existsSync(initDir)) {
|
|
949
|
+
try {
|
|
950
|
+
const files = readdirSync(initDir).filter((f) => f.endsWith(".js") && f !== "index.js");
|
|
951
|
+
for (const file of files) {
|
|
952
|
+
if (claimedFiles.has(file))
|
|
953
|
+
continue;
|
|
954
|
+
const name = file.replace(".js", "");
|
|
955
|
+
const dropinName = reverseAliases.get(name) ??
|
|
956
|
+
(name.startsWith("storefront-") ? name : `storefront-${name}`);
|
|
957
|
+
const filePath = join(initDir, file);
|
|
958
|
+
let content;
|
|
959
|
+
try {
|
|
960
|
+
content = readFileSync(filePath, "utf8");
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
const hasDropinImport = /@dropins\/storefront-/.test(content);
|
|
966
|
+
if (hasDropinImport) {
|
|
967
|
+
findings.push({
|
|
968
|
+
severity: "info",
|
|
969
|
+
category: "initializer",
|
|
970
|
+
dropin: dropinName,
|
|
971
|
+
what: `scripts/initializers/${file}`,
|
|
972
|
+
detail: `Initializer "${file}" references ${dropinName} but no block imports from this dropin. It may be unused or the block has been removed.`,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
catch {
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (fix) {
|
|
981
|
+
return {
|
|
982
|
+
reports,
|
|
983
|
+
findings: augmentFindingsWithFixes(findings, regs.containerReg, regs.slotReg, regs.eventCatalog, regs.apiReg, regs.toolsComponentsReg),
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
return { reports, findings };
|
|
987
|
+
}
|
|
988
|
+
async function checkSingleBlock(projectDir, blockName, regs, fix) {
|
|
989
|
+
const mainSource = readBlockFile(projectDir, blockName);
|
|
990
|
+
if (!mainSource)
|
|
991
|
+
return null;
|
|
992
|
+
const quickImports = extractDropinImports(mainSource);
|
|
993
|
+
const hasToolsComponents = mainSource.includes("@dropins/tools/components");
|
|
994
|
+
if (quickImports.length === 0 && !hasToolsComponents)
|
|
995
|
+
return null;
|
|
996
|
+
const detectedDropins = [...new Set(quickImports.map((i) => i.dropin))];
|
|
997
|
+
const { sources, fileList } = collectBlockSources(projectDir, blockName, mainSource, detectedDropins);
|
|
998
|
+
const usage = extractBlockUsage(sources);
|
|
999
|
+
let findings = compareAgainstRegistry(usage, regs);
|
|
1000
|
+
if (fix) {
|
|
1001
|
+
findings = augmentFindingsWithFixes(findings, regs.containerReg, regs.slotReg, regs.eventCatalog, regs.apiReg, regs.toolsComponentsReg);
|
|
1002
|
+
}
|
|
1003
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
1004
|
+
const warnings = findings.filter((f) => f.severity === "warning");
|
|
1005
|
+
return {
|
|
1006
|
+
block: blockName,
|
|
1007
|
+
healthy: errors.length === 0 && warnings.length === 0,
|
|
1008
|
+
filesAnalyzed: fileList,
|
|
1009
|
+
detectedDropins: usage.detectedDropins,
|
|
1010
|
+
summary: {
|
|
1011
|
+
containersUsed: usage.containers.length,
|
|
1012
|
+
eventsSubscribed: usage.events.length,
|
|
1013
|
+
apiFunctionsUsed: usage.apiFunctions.length,
|
|
1014
|
+
slotsConfigured: usage.slots.length,
|
|
1015
|
+
errors: errors.length,
|
|
1016
|
+
warnings: warnings.length,
|
|
1017
|
+
},
|
|
1018
|
+
findings,
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
async function scanAllBlocks(projectDir, fix) {
|
|
1022
|
+
const regs = loadRegistries();
|
|
1023
|
+
const allBlocks = listAvailableBlocks(projectDir);
|
|
1024
|
+
const results = [];
|
|
1025
|
+
let totalErrors = 0;
|
|
1026
|
+
let totalWarnings = 0;
|
|
1027
|
+
for (const blockName of allBlocks) {
|
|
1028
|
+
const result = await checkSingleBlock(projectDir, blockName, regs, fix);
|
|
1029
|
+
if (!result)
|
|
1030
|
+
continue;
|
|
1031
|
+
results.push(result);
|
|
1032
|
+
totalErrors += result.summary.errors;
|
|
1033
|
+
totalWarnings += result.summary.warnings;
|
|
1034
|
+
}
|
|
1035
|
+
const allUsedDropins = [
|
|
1036
|
+
...new Set(results.flatMap((r) => r.detectedDropins)),
|
|
1037
|
+
];
|
|
1038
|
+
const initHealth = checkInitializerHealth(projectDir, allUsedDropins, regs, fix);
|
|
1039
|
+
const initErrors = initHealth.findings.filter((f) => f.severity === "error").length;
|
|
1040
|
+
const initWarnings = initHealth.findings.filter((f) => f.severity === "warning").length;
|
|
1041
|
+
totalErrors += initErrors;
|
|
1042
|
+
totalWarnings += initWarnings;
|
|
1043
|
+
const healthy = results.filter((r) => r.healthy);
|
|
1044
|
+
const unhealthy = results.filter((r) => !r.healthy);
|
|
1045
|
+
const hasInitIssues = initErrors > 0 || initWarnings > 0;
|
|
1046
|
+
const allHealthy = unhealthy.length === 0 && !hasInitIssues;
|
|
1047
|
+
const parts = [];
|
|
1048
|
+
if (unhealthy.length > 0) {
|
|
1049
|
+
parts.push(`${unhealthy.length} of ${results.length} dropin block(s) have issues`);
|
|
1050
|
+
}
|
|
1051
|
+
if (hasInitIssues) {
|
|
1052
|
+
parts.push(`${initHealth.findings.length} initializer finding(s)`);
|
|
1053
|
+
}
|
|
1054
|
+
const message = allHealthy
|
|
1055
|
+
? `All ${results.length} dropin block(s) and initializers are healthy.`
|
|
1056
|
+
: `${parts.join("; ")} (${totalErrors} error(s), ${totalWarnings} warning(s)).`;
|
|
1057
|
+
return formatSuccessResponse(message, {
|
|
1058
|
+
scanned: results.length,
|
|
1059
|
+
allHealthy,
|
|
1060
|
+
summary: {
|
|
1061
|
+
healthy: healthy.length,
|
|
1062
|
+
unhealthy: unhealthy.length,
|
|
1063
|
+
totalErrors,
|
|
1064
|
+
totalWarnings,
|
|
1065
|
+
},
|
|
1066
|
+
blocks: results,
|
|
1067
|
+
initializers: {
|
|
1068
|
+
checked: allUsedDropins.length,
|
|
1069
|
+
findings: initHealth.findings,
|
|
1070
|
+
reports: initHealth.reports,
|
|
1071
|
+
},
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
export async function checkBlockHealth(params) {
|
|
1075
|
+
try {
|
|
1076
|
+
const guard = projectDirGuard(params.projectDir);
|
|
1077
|
+
if (guard)
|
|
1078
|
+
return guard;
|
|
1079
|
+
const fix = params.fix ?? false;
|
|
1080
|
+
if (!params.block) {
|
|
1081
|
+
return scanAllBlocks(params.projectDir, fix);
|
|
1082
|
+
}
|
|
1083
|
+
const mainSource = readBlockFile(params.projectDir, params.block);
|
|
1084
|
+
if (!mainSource) {
|
|
1085
|
+
const blockDir = join(params.projectDir, "blocks", params.block);
|
|
1086
|
+
return formatSuccessResponse(existsSync(blockDir)
|
|
1087
|
+
? `Block "${params.block}" has no JS file`
|
|
1088
|
+
: `Block "${params.block}" not found in ${params.projectDir}/blocks/`, {
|
|
1089
|
+
error: existsSync(blockDir)
|
|
1090
|
+
? `blocks/${params.block}/${params.block}.js not found`
|
|
1091
|
+
: `Block directory does not exist: blocks/${params.block}/`,
|
|
1092
|
+
availableBlocks: listAvailableBlocks(params.projectDir),
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
const quickImports = extractDropinImports(mainSource);
|
|
1096
|
+
const detectedDropins = [...new Set(quickImports.map((i) => i.dropin))];
|
|
1097
|
+
const { sources, fileList } = collectBlockSources(params.projectDir, params.block, mainSource, detectedDropins);
|
|
1098
|
+
const usage = extractBlockUsage(sources);
|
|
1099
|
+
const regs = loadRegistries();
|
|
1100
|
+
let findings = compareAgainstRegistry(usage, regs);
|
|
1101
|
+
if (fix) {
|
|
1102
|
+
findings = augmentFindingsWithFixes(findings, regs.containerReg, regs.slotReg, regs.eventCatalog, regs.apiReg, regs.toolsComponentsReg);
|
|
1103
|
+
}
|
|
1104
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
1105
|
+
const warnings = findings.filter((f) => f.severity === "warning");
|
|
1106
|
+
const healthy = errors.length === 0 && warnings.length === 0;
|
|
1107
|
+
return formatSuccessResponse(healthy
|
|
1108
|
+
? `Block "${params.block}" is healthy — all usage matches the current registry.`
|
|
1109
|
+
: `Block "${params.block}" has ${errors.length} error(s) and ${warnings.length} warning(s).`, {
|
|
1110
|
+
block: params.block,
|
|
1111
|
+
healthy,
|
|
1112
|
+
filesAnalyzed: fileList,
|
|
1113
|
+
detectedDropins: usage.detectedDropins,
|
|
1114
|
+
summary: {
|
|
1115
|
+
containersUsed: usage.containers.length,
|
|
1116
|
+
eventsSubscribed: usage.events.length,
|
|
1117
|
+
apiFunctionsUsed: usage.apiFunctions.length,
|
|
1118
|
+
slotsConfigured: usage.slots.length,
|
|
1119
|
+
errors: errors.length,
|
|
1120
|
+
warnings: warnings.length,
|
|
1121
|
+
},
|
|
1122
|
+
findings,
|
|
1123
|
+
usage: {
|
|
1124
|
+
containers: usage.containers,
|
|
1125
|
+
containerProps: usage.containerProps,
|
|
1126
|
+
slots: usage.slots,
|
|
1127
|
+
events: usage.events,
|
|
1128
|
+
apiFunctions: usage.apiFunctions,
|
|
1129
|
+
},
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
catch (error) {
|
|
1133
|
+
return formatExceptionResponse(error, "check_block_health");
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
function listAvailableBlocks(projectDir) {
|
|
1137
|
+
const blocksDir = join(projectDir, "blocks");
|
|
1138
|
+
if (!existsSync(blocksDir))
|
|
1139
|
+
return [];
|
|
1140
|
+
try {
|
|
1141
|
+
return readdirSync(blocksDir).filter((name) => {
|
|
1142
|
+
const jsFile = join(blocksDir, name, `${name}.js`);
|
|
1143
|
+
return existsSync(jsFile);
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
catch {
|
|
1147
|
+
return [];
|
|
1148
|
+
}
|
|
1149
|
+
}
|