@wix/agent-skills 1.19.0 → 1.19.1

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 (58) hide show
  1. package/package.json +1 -1
  2. package/skills/wix-app/SKILL.md +33 -31
  3. package/skills/wix-app/references/DASHBOARD_PAGE.md +6 -10
  4. package/skills/wix-app/references/EDITOR_REACT_COMPONENT.md +16 -7
  5. package/skills/wix-app/references/WIX_PATTERNS_DOCS.md +53 -131
  6. package/skills/wix-app/references/dashboard-page/UX_SUCCESS_MODEL.md +1 -2
  7. package/skills/wix-app/references/editor-react-component/ACCESSIBILITY.md +83 -73
  8. package/skills/wix-app/references/editor-react-component/COMPONENT-CONTRACT.md +3 -2
  9. package/skills/wix-app/references/editor-react-component/CSS-GUIDELINES.md +9 -0
  10. package/skills/wix-app/references/editor-react-component/PARTS.md +5 -2
  11. package/skills/wix-app/references/editor-react-component/REACT-GUIDELINES.md +1 -1
  12. package/skills/wix-app/scripts/pkg-root.cjs +73 -0
  13. package/skills/wix-app/scripts/scan-a11y-code.cjs +310 -40
  14. package/skills/wix-app/scripts/scan-a11y-eslint.cjs +118 -24
  15. package/skills/wix-app/scripts/scan-a11y-render.cjs +551 -0
  16. package/skills/wix-app/scripts/scan-a11y-review.cjs +373 -0
  17. package/skills/wix-base44-connector/SKILL.md +24 -22
  18. package/skills/wix-base44-connector/scripts/utils.cjs +39 -3
  19. package/skills/wix-docs/SKILL.md +47 -20
  20. package/skills/wix-headless/entry/bootstrap.mjs +106 -50
  21. package/skills/wix-headless/entry/skill.md +5 -3
  22. package/skills/wix-headless/references/managed/AUTHENTICATION.md +25 -5
  23. package/skills/wix-headless-fast/SKILL.md +25 -7
  24. package/skills/wix-headless-fast/entry/skill.md +23 -14
  25. package/skills/wix-headless-fast/install/fast-path.mjs +48 -5
  26. package/skills/wix-headless-fast/install/pin-agents-md.mjs +57 -0
  27. package/skills/wix-headless-fast/references/shared/CUSTOM_OPERATIONS.md +3 -3
  28. package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +204 -35
  29. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +146 -36
  30. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart-store.ts +8 -2
  31. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +95 -20
  32. package/skills/wix-headless-fast/references/storefront/seed/SEED.md +21 -2
  33. package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +104 -18
  34. package/skills/wix-manage/SKILL.md +13 -7
  35. package/skills/wix-manage/references/google-ads/billing-and-payment.md +3 -3
  36. package/skills/wix-manage/references/google-ads/create-performance-max-campaign.md +8 -8
  37. package/skills/wix-manage/references/google-ads/get-campaign-suggestions.md +4 -4
  38. package/skills/wix-manage/references/google-ads/google-ads-dashboard-navigation.md +1 -1
  39. package/skills/wix-manage/references/google-ads/install-and-create-account.md +9 -9
  40. package/skills/wix-manage/references/google-ads/query-campaign-analytics.md +7 -7
  41. package/skills/wix-manage/references/seo/manage-content-plan.md +181 -135
  42. package/skills/wix-manage/references/seo/manage-google-search-console.md +272 -0
  43. package/skills/wix-manage/references/sites/upload-static-site.md +53 -0
  44. package/skills/wix-manage/references/stores/change-store-currency.md +1 -1
  45. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed-with-install.md +3 -1
  46. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed.md +3 -1
  47. package/skills/wix-vibe-headless/platforms/base44-ecom-light.md +1 -1
  48. package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +1 -1
  49. package/skills/wix-app/references/dashboard-page/COLLECTION_TOOLKIT.md +0 -80
  50. package/skills/wix-app/references/dashboard-page/ENTITY_PAGE_TOOLKIT.md +0 -140
  51. package/skills/wix-app/references/dashboard-page/PATTERNS_BUNDLE_READING.md +0 -112
  52. package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductCard.tsx +0 -100
  53. package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductDetailView.tsx +0 -130
  54. package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductGrid.tsx +0 -48
  55. package/skills/wix-headless-fast/references/storefront/app/components/storefront/ShopView.tsx +0 -66
  56. package/skills/wix-headless-fast/references/storefront/app/components/storefront/VariantPicker.tsx +0 -103
  57. package/skills/wix-headless-fast/references/storefront/app-astro/pages/products/[slug].astro +0 -56
  58. package/skills/wix-headless-fast/references/storefront/app-astro/pages/shop.astro +0 -23
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // oxlint-disable no-console preserve-caught-error
5
+
6
+ /**
7
+ * Accessibility review for Editor React Components: one command, one JSON report.
8
+ *
9
+ * node <SKILL_ROOT>/scripts/scan-a11y-review.cjs <component-dir | files...>
10
+ *
11
+ * Runs the jsx-a11y ESLint scanner and the Babel semantic/contract scanner in
12
+ * process, spawns the render audit (SSR + jsdom + axe-core) per component
13
+ * folder, and groups the findings by rule.
14
+ *
15
+ * Exit codes: 0 every scanner ran and found nothing, 1 findings, 2 a scanner
16
+ * could not run or could not load the component (never treat 2 as clean).
17
+ * Test hook: `A11Y_SKIP_RENDER=1` skips the render audit; the review is then
18
+ * inconclusive (exit 2) even with no findings.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { spawnSync } = require('child_process');
24
+
25
+ const ROOT = process.cwd();
26
+ const RENDER_SCRIPT = path.join(__dirname, 'scan-a11y-render.cjs');
27
+ const RENDER_TIMEOUT_MS = Number(process.env.A11Y_RENDER_TIMEOUT_MS) || 20_000;
28
+ const MAX_LOCATIONS_PER_GROUP = 5;
29
+ const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
30
+ const CONFIDENCE_RANK = { high: 0, medium: 1, low: 2, unknown: 3 };
31
+
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+ // Arguments and discovery
34
+ // ─────────────────────────────────────────────────────────────────────────────
35
+
36
+ function parseArgs(argv) {
37
+ const options = { targets: [], render: process.env.A11Y_SKIP_RENDER !== '1' };
38
+ for (const arg of argv) {
39
+ if (arg.startsWith('--')) throw new Error(`Unknown option ${arg}`);
40
+ options.targets.push(arg);
41
+ }
42
+ return options;
43
+ }
44
+
45
+ const isSource = (name) =>
46
+ /\.(tsx|jsx)$/.test(name) && !/\.(stories|test|spec)\.[jt]sx$/.test(name);
47
+
48
+ function listSources(dir, acc = []) {
49
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
50
+ const full = path.join(dir, entry.name);
51
+ if (entry.isDirectory()) {
52
+ if (entry.name !== 'node_modules') listSources(full, acc);
53
+ } else if (entry.isFile() && isSource(entry.name)) acc.push(full);
54
+ }
55
+ return acc;
56
+ }
57
+
58
+ function isComponentDir(dir) {
59
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return false;
60
+ const names = fs.readdirSync(dir);
61
+ return names.includes('component.tsx') || names.some((name) => name.endsWith('.props.ts'));
62
+ }
63
+
64
+ function findComponentDir(file) {
65
+ let dir = path.dirname(file);
66
+ for (let depth = 0; depth < 6 && !isComponentDir(dir); depth++) {
67
+ const parent = path.dirname(dir);
68
+ if (parent === dir) return path.dirname(file);
69
+ dir = parent;
70
+ }
71
+ return isComponentDir(dir) ? dir : path.dirname(file);
72
+ }
73
+
74
+ /** Expand targets (component folders, a components root, or files) into files and component folders. */
75
+ function discoverFiles(targets) {
76
+ const files = new Set();
77
+ const componentDirs = new Set();
78
+ for (const target of targets) {
79
+ const resolved = path.resolve(target);
80
+ if (!fs.existsSync(resolved)) throw new Error(`Path not found: ${target}`);
81
+ if (fs.statSync(resolved).isDirectory()) {
82
+ if (isComponentDir(resolved)) componentDirs.add(resolved);
83
+ else {
84
+ for (const entry of fs.readdirSync(resolved, { withFileTypes: true })) {
85
+ const child = path.join(resolved, entry.name);
86
+ if (entry.isDirectory() && isComponentDir(child)) componentDirs.add(child);
87
+ }
88
+ }
89
+ for (const file of listSources(resolved)) files.add(file);
90
+ } else {
91
+ files.add(resolved);
92
+ componentDirs.add(findComponentDir(resolved));
93
+ }
94
+ }
95
+ return { files: [...files].toSorted(), componentDirs: [...componentDirs].toSorted() };
96
+ }
97
+
98
+ // ─────────────────────────────────────────────────────────────────────────────
99
+ // Scanners
100
+ // ─────────────────────────────────────────────────────────────────────────────
101
+
102
+ async function runScanner(name, load) {
103
+ const started = Date.now();
104
+ try {
105
+ const value = await load();
106
+ const parseErrors = (value.meta && value.meta.parseErrors) || [];
107
+ if (parseErrors.length) {
108
+ throw new Error(parseErrors.map((e) => `${e.file}: ${e.message}`).join('; '));
109
+ }
110
+ return { ok: true, ms: Date.now() - started, value };
111
+ } catch (error) {
112
+ const message = error && error.message ? error.message : String(error);
113
+ return { ok: false, ms: Date.now() - started, error: `${name}: ${message}` };
114
+ }
115
+ }
116
+
117
+ function eslintFindings(report) {
118
+ return report.findings.map((f) => ({
119
+ rule: f.rule,
120
+ source: 'eslint',
121
+ severity: 'high',
122
+ confidence: 'high',
123
+ message: f.message,
124
+ at: `${f.file}:${f.line}:${f.column}`,
125
+ }));
126
+ }
127
+
128
+ function semanticFindings(report) {
129
+ return report.findings.map((f) => {
130
+ const contract = f.sourceKind === 'contract';
131
+ const severity =
132
+ contract || f.confidence === 'high' ? 'high' : f.confidence === 'medium' ? 'medium' : 'low';
133
+ return {
134
+ rule: f.rule,
135
+ source: contract ? 'contract' : 'semantic',
136
+ severity,
137
+ confidence: f.confidence,
138
+ message: f.message,
139
+ detail: contract ? null : f.evidence,
140
+ at: `${f.file}:${f.line}:${f.column}`,
141
+ };
142
+ });
143
+ }
144
+
145
+ function runRenderChild(componentDir) {
146
+ const relativeDir = path.relative(ROOT, componentDir) || '.';
147
+ const started = Date.now();
148
+ const result = spawnSync(process.execPath, [RENDER_SCRIPT, componentDir], {
149
+ cwd: ROOT,
150
+ encoding: 'utf8',
151
+ timeout: RENDER_TIMEOUT_MS,
152
+ killSignal: 'SIGKILL',
153
+ maxBuffer: 8 * 1024 * 1024,
154
+ env: { ...process.env, NODE_ENV: process.env.NODE_ENV || 'development' },
155
+ });
156
+ const base = { componentDir: relativeDir, ms: Date.now() - started };
157
+
158
+ if (result.error && result.error.code === 'ETIMEDOUT') {
159
+ return {
160
+ ...base,
161
+ ok: false,
162
+ reason: 'timeout',
163
+ findings: [
164
+ {
165
+ rule: 'render-timeout',
166
+ source: 'render',
167
+ severity: 'high',
168
+ confidence: 'high',
169
+ message: `Rendering ${relativeDir} did not finish within ${RENDER_TIMEOUT_MS} ms. Rerun once; if it repeats, the first render never settles.`,
170
+ },
171
+ ],
172
+ };
173
+ }
174
+
175
+ let report = null;
176
+ try {
177
+ const lastLine = String(result.stdout || '')
178
+ .trim()
179
+ .split('\n')
180
+ .filter(Boolean)
181
+ .pop();
182
+ report = lastLine ? JSON.parse(lastLine) : null;
183
+ } catch {
184
+ report = null;
185
+ }
186
+ if (!report) {
187
+ const error = truncate(result.stderr || `exit ${result.status}`, 400);
188
+ return { ...base, ok: false, reason: 'crash', error, findings: [] };
189
+ }
190
+ if (!report.ok) {
191
+ return {
192
+ ...base,
193
+ ok: false,
194
+ reason: report.reason || 'error',
195
+ error: report.error || null,
196
+ findings: [],
197
+ };
198
+ }
199
+ return {
200
+ ...base,
201
+ ok: true,
202
+ entry: report.entry,
203
+ preview: report.preview,
204
+ ssr: report.ssr,
205
+ axeRules: report.axe ? report.axe.rulesRun : null,
206
+ notChecked: report.notChecked || [],
207
+ findings: report.findings.map((f) => ({
208
+ ...f,
209
+ source: 'render',
210
+ at: f.target ? `${relativeDir} ${f.target}` : relativeDir,
211
+ })),
212
+ };
213
+ }
214
+
215
+ // ─────────────────────────────────────────────────────────────────────────────
216
+ // Report
217
+ // ─────────────────────────────────────────────────────────────────────────────
218
+
219
+ function truncate(text, length) {
220
+ const compact = String(text || '').replace(/\s+/g, ' ');
221
+ return compact.length > length ? `${compact.slice(0, length - 1)}…` : compact;
222
+ }
223
+
224
+ /** One entry per (source, rule, message); repeated nodes become a count plus a few locations. */
225
+ function groupFindings(findings, { capPerGroup = MAX_LOCATIONS_PER_GROUP } = {}) {
226
+ const groups = new Map();
227
+ for (const f of findings) {
228
+ const severity = f.severity in SEVERITY_RANK ? f.severity : 'medium';
229
+ const confidence = f.confidence in CONFIDENCE_RANK ? f.confidence : 'medium';
230
+ const key = `${f.source}|${f.rule}|${f.message}`;
231
+ if (!groups.has(key)) {
232
+ groups.set(key, {
233
+ rule: f.rule,
234
+ src: f.source,
235
+ sev: severity,
236
+ conf: confidence,
237
+ n: 0,
238
+ at: [],
239
+ html: f.html || null,
240
+ msg: f.message,
241
+ detail: f.detail || null,
242
+ helpUrl: f.helpUrl || null,
243
+ });
244
+ }
245
+ const group = groups.get(key);
246
+ group.n++;
247
+ if (f.at && group.at.length < capPerGroup && !group.at.includes(f.at)) group.at.push(f.at);
248
+ if (SEVERITY_RANK[severity] < SEVERITY_RANK[group.sev]) group.sev = severity;
249
+ if (CONFIDENCE_RANK[confidence] < CONFIDENCE_RANK[group.conf]) group.conf = confidence;
250
+ }
251
+ return [...groups.values()].toSorted(
252
+ (a, b) =>
253
+ SEVERITY_RANK[a.sev] - SEVERITY_RANK[b.sev] ||
254
+ CONFIDENCE_RANK[a.conf] - CONFIDENCE_RANK[b.conf] ||
255
+ b.n - a.n ||
256
+ a.rule.localeCompare(b.rule),
257
+ );
258
+ }
259
+
260
+ function renderLabel(result) {
261
+ if (result.ok) {
262
+ if (!result.ssr || !result.ssr.ok) return `render: ssr FAILED (${result.entry || '?'})`;
263
+ return `render ok (${result.entry || '?'}, axe ${result.axeRules ?? '-'})`;
264
+ }
265
+ return `render FAILED (${result.reason})`;
266
+ }
267
+
268
+ function buildSummary(groups, scanners, { renderEnabled = true } = {}) {
269
+ const counts = { high: 0, medium: 0, low: 0, groups: groups.length, findings: 0 };
270
+ for (const group of groups) {
271
+ counts[group.sev] += group.n;
272
+ counts.findings += group.n;
273
+ }
274
+ // A timeout is itself a finding; any other render failure (missing tooling,
275
+ // a module the audit cannot load) or a skipped render means the audit did
276
+ // not run, never clean.
277
+ const staticFailed = !scanners.eslint.ok || !scanners.semantic.ok;
278
+ const renderBroken =
279
+ !renderEnabled || scanners.render.some((r) => !r.ok && r.reason !== 'timeout');
280
+ const status =
281
+ staticFailed || renderBroken ? 'error' : counts.findings > 0 ? 'findings' : 'clean';
282
+ const severities = ['high', 'medium', 'low']
283
+ .filter((level) => counts[level] > 0)
284
+ .map((level) => `${counts[level]} ${level}`);
285
+ const line = [
286
+ `A11Y ${counts.groups} groups/${counts.findings} findings${severities.length ? ` (${severities.join(', ')})` : ''}`,
287
+ `eslint ${scanners.eslint.ok ? 'ok' : 'FAILED'}`,
288
+ `semantic ${scanners.semantic.ok ? 'ok' : 'FAILED'}`,
289
+ renderEnabled
290
+ ? scanners.render.map(renderLabel).join(' · ') || 'render: no component folder'
291
+ : 'render SKIPPED (inconclusive)',
292
+ ].join(' · ');
293
+ return { status, line, counts };
294
+ }
295
+
296
+ const computeExitCode = (summary) =>
297
+ summary.status === 'error' ? 2 : summary.counts.findings > 0 ? 1 : 0;
298
+
299
+ async function review(options) {
300
+ const { files, componentDirs } = discoverFiles(options.targets);
301
+ // A wrong path (a typo, a parent folder) must never read as clean.
302
+ if (componentDirs.length === 0) {
303
+ throw new Error(
304
+ `No component folder under ${options.targets.join(', ')}: expected component.tsx or *.props.ts.`,
305
+ );
306
+ }
307
+
308
+ // ESLint rejects an empty file list; a folder with no JSX still gets its render audit.
309
+ const eslint = await runScanner('eslint', () =>
310
+ files.length ? require('./scan-a11y-eslint.cjs').scan(files) : { findings: [] },
311
+ );
312
+ const semantic = await runScanner('semantic', () =>
313
+ files.length ? require('./scan-a11y-code.cjs').scan(files) : { findings: [] },
314
+ );
315
+ const render = options.render ? componentDirs.map(runRenderChild) : [];
316
+
317
+ const findings = [
318
+ ...(eslint.ok ? eslintFindings(eslint.value) : []),
319
+ ...(semantic.ok ? semanticFindings(semantic.value) : []),
320
+ ...render.flatMap((r) => r.findings),
321
+ ];
322
+ const groups = groupFindings(findings);
323
+
324
+ const notChecked = new Set(options.render ? [] : ['render audit (skipped)']);
325
+ for (const r of render) {
326
+ for (const item of r.notChecked || []) notChecked.add(item);
327
+ }
328
+
329
+ const scanners = {
330
+ eslint: { ok: eslint.ok, ms: eslint.ms, error: eslint.error || null },
331
+ semantic: { ok: semantic.ok, ms: semantic.ms, error: semantic.error || null },
332
+ render: render.map(({ findings: _findings, notChecked: _notChecked, ...rest }) => rest),
333
+ };
334
+ const summary = buildSummary(groups, scanners, { renderEnabled: options.render });
335
+ return {
336
+ summary,
337
+ findings: groups,
338
+ scanners,
339
+ files: files.map((file) => path.relative(ROOT, file)),
340
+ notChecked: [...notChecked],
341
+ exitCode: computeExitCode(summary),
342
+ };
343
+ }
344
+
345
+ async function main() {
346
+ let options;
347
+ try {
348
+ options = parseArgs(process.argv.slice(2));
349
+ if (options.targets.length === 0) throw new Error('No component directory or files specified.');
350
+ } catch (error) {
351
+ console.error(
352
+ `${error.message}\nUsage: node <SKILL_ROOT>/scripts/scan-a11y-review.cjs <component-dir | files...>`,
353
+ );
354
+ process.exit(2);
355
+ }
356
+ try {
357
+ const report = await review(options);
358
+ console.log(JSON.stringify(report));
359
+ process.exit(report.exitCode);
360
+ } catch (error) {
361
+ console.log(
362
+ JSON.stringify({
363
+ summary: { status: 'error', line: error.message, counts: { findings: 0 } },
364
+ findings: [],
365
+ }),
366
+ );
367
+ process.exit(2);
368
+ }
369
+ }
370
+
371
+ module.exports = { review, discoverFiles, groupFindings, buildSummary, computeExitCode };
372
+
373
+ if (require.main === module) main();
@@ -47,6 +47,9 @@ management tools, or do both.
47
47
  visitor, never the admin connector token. Call Wix directly from the browser through one shared
48
48
  visitor client (Write the code, below). Redirect sessions for Wix-hosted flows also require a
49
49
  visitor token minted for the headless OAuth app; see Visitor authentication and Wix-hosted flows below.
50
+ **The OAuth app is a one-call prerequisite, not a dead end**: `wx.ensureOAuthApp` returns its
51
+ `clientId`, creating the app when the site has none, so it is a visitor flow's first step — never
52
+ a reason to move the flow onto the admin token or leave it for later.
50
53
  Anyone can mint an anonymous visitor token from the
51
54
  OAuth app's public `clientId`; no visitor login is required. APIs for the "current visitor"
52
55
  use that token to identify whose data and state to access. This applies both to a standalone
@@ -90,6 +93,7 @@ const wx = require(require("path").resolve(P));
90
93
  - `wx.bash(cmd)` — shell over saved files (GNU grep/sed; awk is mawk; no rg)
91
94
  - `wx.spec(docsUrl | code)` — a method's exact schema, plus the titles of the docs' own request examples saved at `examplesPath`; pass a hit's docsUrl (direct load), or raw code to query the index yourself
92
95
  - `wx.mgmtRecipes(q?)` — management-recipe index; no arg → categories, a word → matching recipes
96
+ - `wx.ensureOAuthApp(token, { name, redirectUris, redirectDomains })` — the headless OAuth app, found by name or created, with the redirect lists merged into whatever it already allows; returns `{ clientId, created }`. `clientId` is what the browser mints visitor tokens from. Run it whenever `context` reports no OAuth app, or before any Wix-hosted flow that returns to this app — see Visitor authentication and Wix-hosted flows
93
97
  - `wx.installApp(appDefId, siteId, token)` — install a Wix app on the site (Apps Installer). If discovery finds an API whose app isn't installed on the site, install it first — that's a one-call prerequisite, **not** a reason to fall back to a hand-built alternative. `appDefId` from `search` or the Apps-Created-by-Wix table; `siteId` from `context` (the site report)
94
98
 
95
99
  Search makes one combined request across REST, management recipes, and Headless by default.
@@ -228,7 +232,9 @@ Want to shape the result yourself? Pass raw code against the index (`lightIndex`
228
232
  await wx.spec(`
229
233
  const url = "<docsUrl from search/browse>"; // API method page, not a skill/article page
230
234
  const s = await getResourceSchemaByUrl(url);
231
- const m = s.methods.find(x => x.docsUrl === url);
235
+ // A method URL resolves to exactly that method — take methods[0]. Don't match on docsUrl:
236
+ // the reader normalizes the url, so equality against the one you passed can miss.
237
+ const m = s.methods[0];
232
238
  return {
233
239
  call: m.publicUrl, // callable https://www.wixapis.com/… URL
234
240
  body: m.requestBody?.content["application/json"].schema.properties,
@@ -327,13 +333,15 @@ return await wx.post("<a public read from Learn Wix>", { query: {} }, visitorTok
327
333
 
328
334
  **Any Wix-hosted flow that returns the visitor to your app needs the headless OAuth app's
329
335
  redirect config set.** This covers redirect sessions *and* sending a buyer to the Wix-hosted
330
- checkout `checkoutUrl` and back — any flow where Wix redirects to a URL on your app. Use the
331
- OAuth app's `clientId` from `wx.context()`; if the report has no OAuth app, create one with the
332
- admin connector token as shown below, then mint a visitor token from its `clientId` (anonymous
333
- visitors do not need to log in). **Always set `allowedRedirectUris` and `allowedRedirectDomains`
334
- when you create it** — an OAuth app created with a name only cannot complete any return, and the
335
- break is silent (create-checkout and the anonymous token still succeed) until a real buyer is
336
- redirected and stranded on Wix.
336
+ checkout `checkoutUrl` and back — any flow where Wix redirects to a URL on your app.
337
+
338
+ **Step one of any visitor flow: `wx.ensureOAuthApp` with this app's own URLs.** It returns the
339
+ `clientId` the frontend mints visitor tokens from (anonymous visitors do not need to log in),
340
+ creating the OAuth app when the site has none and adding your URLs to its lists when it already
341
+ has one. Returns only complete for URLs in those lists, and a missing one breaks silently —
342
+ create-checkout and the anonymous token still succeed — until a real buyer is redirected and
343
+ stranded on Wix. It is idempotent by name: never create a second OAuth app for the same Base44
344
+ app, or the first `clientId`'s returns stop working.
337
345
 
338
346
  ```js
339
347
  // Register BOTH of this app's own URLs — its preview URL and its published URL — so returns work
@@ -353,20 +361,14 @@ const returnDomains = appOrigins.map(origin => new URL(origin).hostname);
353
361
  // OAuth redirect configuration: exact login URLs versus domains for other returns.
354
362
  // https://dev.wix.com/docs/go-headless/authentication/setup/allow-redirect-uris-and-domains.md
355
363
  const { accessToken: adminToken } = await base44.asServiceRole.connectors.getConnection("wix");
356
- const { oAuthApp } = await wx.post("https://www.wixapis.com/oauth-app/v1/oauth-apps", {
357
- oAuthApp: {
358
- name: "My App",
359
- // Login callbacks: the authorization request's redirect URI must match exactly.
360
- allowedRedirectUris: loginCallbacks,
361
- // Returns from Wix-hosted flows: hostnames only, allowing URLs under each domain.
362
- allowedRedirectDomains: returnDomains,
363
- },
364
- }, adminToken);
365
- const clientId = oAuthApp.id; // Public visitor client ID, used by the frontend client above.
366
-
367
- // If destinations change later, update this OAuth app rather than creating another.
368
- // Read its existing lists and merge new entries before updating, preserving old entries.
369
- // https://dev.wix.com/docs/api-reference/business-management/headless/oauth-apps/update-oauth-app.md
364
+ const { clientId, created } = await wx.ensureOAuthApp(adminToken, {
365
+ name: "My App", // this app's name; the lookup key on later runs
366
+ redirectUris: loginCallbacks, // login callbacks: matched exactly by the auth request
367
+ redirectDomains: returnDomains, // returns from Wix-hosted flows: hostnames only
368
+ });
369
+ // clientId is the public visitor client id the frontend client above mints from.
370
+ // When destinations change later — a custom domain, a renamed app — call this again with the
371
+ // new URLs: it updates the same OAuth app and preserves the entries already there.
370
372
 
371
373
  ```
372
374
 
@@ -353,9 +353,13 @@ async function spec(arg) {
353
353
  const code = /^async function/.test(s) ? s
354
354
  : /^https:\/\/dev\.wix\.com\/docs\//.test(s) ? "async function(){ return await getResourceSchemaByUrl(" + JSON.stringify(s) + "); }"
355
355
  : "async function(){ " + s + " }";
356
- const { result } = await post("https://mcp.wix.com/api/code-mode/search", { code });
356
+ const { result, error } = await post("https://mcp.wix.com/api/code-mode/search", { code });
357
+ // The envelope is 200 even when the query throws — the cause is in `error`, not the status.
358
+ // Surfacing it matters: swallowing it turns "your code threw" into "nothing matched".
359
+ if (error)
360
+ return { error, note: "the query threw — read the message and fix the code; don't re-send it unchanged" };
357
361
  if (result == null || (Array.isArray(result) && !result.length))
358
- return { result, note: "empty — the query missed the shape; match your docsUrl against s.methods[].docsUrl" };
362
+ return { result, note: "the query ran but matched nothing — widen it, or check the url resolves" };
359
363
  const text = JSON.stringify(result, null, 1);
360
364
  if (text.length <= BUDGET) return result;
361
365
  let h = 5381;
@@ -424,4 +428,36 @@ async function installApp(appDefId, siteId, token) {
424
428
  { tenant: { tenantType: "SITE", id: siteId }, appInstance: { appDefId } }, token);
425
429
  }
426
430
 
427
- module.exports = { req, post, get, patch, put, del, clip, context, browse, search, page, bash, spec, mgmtRecipes, installApp };
431
+ // The headless OAuth app: the visitor client id, and the redirect config every Wix-hosted
432
+ // return needs. Idempotent by name — a site reached by the "connect an existing site" flow
433
+ // has none, and creating a second one for the same app strands the first app's returns.
434
+ // Redirect lists are merged, never replaced: an app that already serves a custom domain
435
+ // keeps it when a preview URL is added.
436
+ async function ensureOAuthApp(token, { name, redirectUris = [], redirectDomains = [] }) {
437
+ const { oAuthApps = [] } = await post(
438
+ "https://www.wixapis.com/oauth-app/v1/oauth-apps/query", { query: {} }, token);
439
+ const existing = oAuthApps.find((a) => a.name === name);
440
+ const merge = (a = [], b = []) => [...new Set([...a, ...b])];
441
+ if (!existing) {
442
+ const { oAuthApp } = await post("https://www.wixapis.com/oauth-app/v1/oauth-apps", {
443
+ oAuthApp: { name, allowedRedirectUris: redirectUris, allowedRedirectDomains: redirectDomains },
444
+ }, token);
445
+ return { clientId: oAuthApp.id, created: true, oAuthApp };
446
+ }
447
+ const allowedRedirectUris = merge(existing.allowedRedirectUris, redirectUris);
448
+ const allowedRedirectDomains = merge(existing.allowedRedirectDomains, redirectDomains);
449
+ const unchanged =
450
+ allowedRedirectUris.length === (existing.allowedRedirectUris || []).length &&
451
+ allowedRedirectDomains.length === (existing.allowedRedirectDomains || []).length;
452
+ if (unchanged) return { clientId: existing.id, created: false, oAuthApp: existing };
453
+ // The update docs disagree with themselves on the mask field (prose says `paths`, the
454
+ // curl example says `path`); send both so the call does not silently no-op.
455
+ const paths = ["allowedRedirectUris", "allowedRedirectDomains"];
456
+ const { oAuthApp } = await patch(
457
+ `https://www.wixapis.com/oauth-app/v1/oauth-apps/${existing.id}`,
458
+ { oAuthApp: { id: existing.id, allowedRedirectUris, allowedRedirectDomains },
459
+ mask: { paths, path: paths.join(",") } }, token);
460
+ return { clientId: (oAuthApp || existing).id, created: false, oAuthApp: oAuthApp || existing };
461
+ }
462
+
463
+ module.exports = { req, post, get, patch, put, del, clip, context, browse, search, page, bash, spec, mgmtRecipes, installApp, ensureOAuthApp };
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: wix-docs
3
- description: "Look up the Wix API/SDK documentation to confirm an exact endpoint, HTTP method, request/response shape, field, enum, or error before writing Wix code — never guess a Wix API from memory. A lookup is a short flow: find the right page, then read it. Two ways: (1) plain `curl` (zero dependencies) — find a page by **semantic search** (`POST /mcp-docs-search/v1/docs/search`, natural-language `{ search_term, document_type }`, incl. the SKILLS recipe corpus for multi-step workflows) **or by browsing** a docs portal as a menu — a structured, typed, counted browse of the REST, SDK, CLI, Build Apps, and Headless portals (`POST /mcp-docs-search/v1/docs/menu/browse`), or the `.md` menu tree from the `llms.txt` root for any surface — then read the page by appending `.md` to its URL; (2) the Wix MCP doc tools when present. Triggers: look up a Wix API, find the Wix endpoint/method, confirm a Wix request body or field, verify a Wix API shape, explore Wix docs, which Wix API do I call, read a Wix method schema."
3
+ description: "Look up the Wix API/SDK documentation to confirm an exact endpoint, HTTP method, request/response shape, field, enum, or error before writing Wix code — never guess a Wix API from memory. A lookup is a short flow: find the right page, then read it. Two ways: (1) plain `curl` (zero dependencies) — find a page by **semantic search** (`POST /mcp-docs-search/v1/docs/search`, natural-language `{ search_term, document_type(s) }`, incl. the SKILLS recipe corpus for multi-step workflows) **or by browsing** a docs portal as a menu — a structured, typed, counted browse of the REST, SDK, CLI, Build Apps, and Headless portals (`POST /mcp-docs-search/v1/docs/menu/browse`), or the `.md` menu tree from the `llms.txt` root for any surface — then read the page by appending `.md` to its URL; (2) the Wix MCP doc tools when present. Triggers: look up a Wix API, find the Wix endpoint/method, confirm a Wix request body or field, verify a Wix API shape, explore Wix docs, which Wix API do I call, read a Wix method schema."
4
4
  ---
5
5
 
6
6
  # Wix Docs — look up the Wix API/SDK documentation
@@ -15,11 +15,11 @@ or the Wix MCP doc tools if your agent has them (Lane 2). Either way, route by w
15
15
  know:
16
16
 
17
17
  - **You have a docs URL** → just read it (§2). Don't re-search for a page you can already name.
18
- - **A multi-step workflow** ("take a booking from service setup to payment") → look for a **recipe**
19
- first: semantic search with `document_type: "SKILLS"` (§1A). A recipe carries step ordering,
20
- cross-step gotchas, and the one bundled endpoint that does the whole job — things no single
21
- method page states. No relevant recipe → search the relevant API corpus and assemble the workflow
22
- from verified per-method contracts.
18
+ - **A multi-step workflow** ("take a booking from service setup to payment") → include the **recipe**
19
+ corpus in the search: `document_types: ["SKILLS", …]` alongside the API corpus the steps live in
20
+ (§1A). A recipe carries step ordering, cross-step gotchas, and the one bundled endpoint that does
21
+ the whole job — things no single method page states. No relevant recipe comes back → assemble the
22
+ workflow from the verified per-method contracts in the same result set.
23
23
  - **One specific operation, field, or enum** → search its API corpus (`REST` / `SDK`), then read or
24
24
  schema-check what you land on.
25
25
 
@@ -34,12 +34,21 @@ Three ways to reach the right page — use whichever fits.
34
34
 
35
35
  **A. Semantic search.** Describe what you want in natural language ("let a customer book an
36
36
  appointment"), not just keywords; hits come back ranked by relevance. Same `POST` body for both
37
- variants: `search_term` (required, 1–500), `document_type` (`REST` default · `SDK` · `SKILLS` ·
38
- `WIX_HEADLESS` · `BUSINESS_SOLUTIONS` · `VELO` · `WDS` · `BUILD_APPS` · `CLI` · `OVERVIEW`),
39
- `maximum_results` (1–20, def 15), `lines_in_each_result` (0–200, def 20; `0` = no per-hit line cap).
40
- `SKILLS` is the dedicated **recipe corpus** — multi-step workflow pages that a `REST` search does
41
- not return; `OVERVIEW` is platform orientation (which development approach, which API family). Two
42
- variants — pick by what you're doing:
37
+ variants: `search_term` (required, 1–500), **one** of `document_type` (a single corpus) or
38
+ `document_types` (an array — several corpora in one ranked call); `maximum_results` (1–20, def 15,
39
+ counted across the combined result set), `lines_in_each_result` (0–200, def 20; `0` = no per-hit
40
+ line cap). Corpora: `REST` (default) · `SDK` · `SKILLS` · `WIX_HEADLESS` · `BUSINESS_SOLUTIONS` ·
41
+ `VELO` · `WDS` · `BUILD_APPS` · `CLI` · `OVERVIEW`. `SKILLS` is the dedicated **recipe corpus** —
42
+ multi-step workflow pages a single-API search does not return; `OVERVIEW` is platform orientation
43
+ (which development approach, which API family).
44
+
45
+ **Search the corpora a question actually spans, in one call.** The service ranks them together and
46
+ interleaves the hits, so they compete on relevance instead of you guessing which to try first —
47
+ and one round trip replaces several. There's no fixed combination: pick by the question. Passing
48
+ both `document_type` and `document_types` is an error (*"Pass either document_type or
49
+ document_types, not both"*), and an unknown corpus name is rejected with the valid list.
50
+
51
+ Two variants — pick by what you're doing:
43
52
 
44
53
  **`/docs/search/markdown` → read it (start here).** Returns JSON with a single `content` field
45
54
  holding one LLM-ready markdown string (extract it with `jq -r '.content'`) where each hit is a
@@ -58,26 +67,44 @@ curl -sS -X POST 'https://www.wixapis.com/mcp-docs-search/v1/docs/search/markdow
58
67
  | jq -r '.content' # no jq? → python3 -c 'import sys,json;print(json.load(sys.stdin)["content"])'
59
68
  ```
60
69
 
61
- For a workflow, hit the recipe corpus first, then resolve each step's call in `REST`/`SDK`:
70
+ A question that spans a workflow and its individual calls asks for both corpora at once — the
71
+ recipe and the per-method contracts arrive in one ranked list:
62
72
 
63
73
  ```bash
64
74
  curl -sS -X POST 'https://www.wixapis.com/mcp-docs-search/v1/docs/search/markdown' \
65
75
  -H 'Content-Type: application/json' \
66
- --data-raw '{"search_term":"end to end booking flow","document_type":"SKILLS","maximum_results":2}' \
76
+ --data-raw '{"search_term":"end to end booking flow","document_types":["SKILLS","REST"],"maximum_results":6}' \
67
77
  | jq -r '.content'
68
78
  ```
69
79
 
70
80
  **`/docs/search` (JSON) → route on it.** Returns `{ results: [ { title, url, content,
71
- relevance_score, … } ] }` — structured hits. Use it when you want to **pick/route programmatically**:
72
- grab a hit's `url` to read that page (§2) or feed it to the schema query (§C). (Method hits carry a
73
- `url`; article hits keep their link inside `content`.)
81
+ relevance_score, kb_name, … } ] }` — structured hits. Use it when you want to **pick/route
82
+ programmatically**: grab a hit's `url` to read that page (§2) or feed it to the schema query (§C).
83
+
84
+ **`kb_name` tells you what each hit is** — every hit carries one, and the kind decides what you can
85
+ do with it: only a method page has a schema to pull (§C); an article or recipe is read as prose (§2).
86
+
87
+ The request can't make that split for you. **A corpus mixes kinds** — searching the API reference
88
+ returns method pages *and* the prose around them — and no corpus returns method pages alone. So
89
+ pick corpora with `document_types`, then separate the kinds on the way out with `kb_name`. Two
90
+ things not to do instead: don't infer the kind from the presence of a `url` (every hit has one,
91
+ articles and recipes included), and don't infer it from the corpus you asked for.
92
+
93
+ Most values name their kind (`…_METHODS_…` for method pages, `…_DOCS_…` for prose, `SKILLS_KB_ID`
94
+ for recipes) but not all do — the SDK's method index is `API_REFERENCE_SDK_KB_ID`. So list the
95
+ labels your search actually returned, then act on those, rather than pattern-matching the name or
96
+ hard-coding a list of ids.
74
97
 
75
98
  ```bash
99
+ # see what came back — label, title, url
76
100
  curl -sS -X POST 'https://www.wixapis.com/mcp-docs-search/v1/docs/search' \
77
101
  -H 'Content-Type: application/json' \
78
- --data-raw '{"search_term":"create a booking","document_type":"REST","maximum_results":5}' \
79
- | jq -r '.results[] | select(.url) | "\(.title)\t\(.url)"'
80
- # no jq? → python3 -c 'import sys,json;[print(r["title"],r["url"]) for r in json.load(sys.stdin)["results"] if r.get("url")]'
102
+ --data-raw '{"search_term":"end to end booking flow","document_types":["REST","SKILLS","WIX_HEADLESS"],"maximum_results":10}' \
103
+ | jq -r '.results[] | "\(.kb_name)\t\(.title)\t\(.url)"'
104
+ # no jq? → python3 -c 'import sys,json;[print(r.get("kb_name"),r["title"],r.get("url")) for r in json.load(sys.stdin)["results"]]'
105
+
106
+ # then take the kind you need, by the label you just saw
107
+ … | jq -r '.results[] | select(.kb_name == "REST_METHODS_KB_ID") | "\(.title)\t\(.url)"'
81
108
  ```
82
109
 
83
110
  **B. Browse the docs tree as a menu.** Two ways: the **structured browse endpoint** for the