@lacneu/wix-openclaw 0.2.2 → 0.4.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.
@@ -0,0 +1,1875 @@
1
+ // Wix SEO tools — auditing a site's SEO, and managing its URL redirects.
2
+ //
3
+ // DEVELOPER PREVIEW, AND OPT-IN. Every endpoint used here is listed in Wix's
4
+ // Developer Preview: Wix may change them at any time and says they "shouldn't
5
+ // be used on live sites". `buildAllTools` registers them only when
6
+ // `config.enableSeoTools` is true, because four of them also write
7
+ // irreversibly to a live site — the two risks compound, and neither should be
8
+ // taken on someone's behalf by a default.
9
+ //
10
+ // Two APIs, two base paths:
11
+ // - Item/Site SEO tags: `/promote/seo/v1/...`
12
+ // - Redirects: `/seo-redirects-service/v1/...`
13
+ //
14
+ // Both sit behind ONE Wix permission, `SCOPE.PROMOTE.MANAGE-SEO`. Wix has no
15
+ // read-only SEO scope, so a key that can audit can also destroy redirects: the
16
+ // read/write boundary is this plugin's (`enableSeoTools` + approvals), not Wix's.
17
+ //
18
+ // `wix_seo_list_item_tags` is the entry point for an audit: it enumerates the
19
+ // items of one type — `STATIC_PAGE` gives the site's pages — each with the tags
20
+ // it sets itself, whether it sets any at all, and the tags it is expected to
21
+ // render with, marked with where each came from.
22
+ //
23
+ // TWO LIMITS THE MODEL MUST KNOW, so they live in the tool descriptions rather
24
+ // than only here:
25
+ //
26
+ // 1. For `STATIC_PAGE`, every read reflects the SAVED revision, never the
27
+ // published one, and `publishStatus` is always `PUBLISH_STATUS_UNSPECIFIED`.
28
+ // An audit that doesn't say so reports on a draft as if it were the live
29
+ // page.
30
+ // 2. `resolvedTags` is derived from the sources Wix manages. Tags that site
31
+ // code, apps or page components add at render time aren't in it, so it is
32
+ // not a copy of the page's rendered `<head>`.
33
+ //
34
+ // Verified against:
35
+ // - https://dev.wix.com/docs/api-reference/business-management/seo/item-seo-tags-v1/list-item-seo-tags.md
36
+ // - https://dev.wix.com/docs/api-reference/business-management/seo/item-seo-tags-v1/get-item-seo-tags.md
37
+ // - https://dev.wix.com/docs/api-reference/business-management/seo/site-seo-tags-v1/get-site-seo-tags.md
38
+ // - https://dev.wix.com/docs/api-reference/business-management/seo/redirects/skills/manage-url-redirects-on-a-wix-site.md
39
+ import { createHmac, randomBytes } from "node:crypto";
40
+ import { Type } from "@sinclair/typebox";
41
+ import { defineWixTool } from "./_factory.js";
42
+ import { compactQuery, } from "./_query.js";
43
+ const SiteIdParam = Type.Optional(Type.String());
44
+ /** Minted once per process. See `redirectToken`. */
45
+ const TOKEN_SECRET = randomBytes(32);
46
+ /** Stop before doing anything irreversible for a caller who has given up. */
47
+ function throwIfAborted(signal) {
48
+ if (signal?.aborted === true) {
49
+ throw new Error("Aborted before the write: nothing was sent to Wix.");
50
+ }
51
+ }
52
+ /** Base path of the SEO redirects service. Its routes are NOT REST-conventional
53
+ * — creation is `POST /create-redirect`, and the bulk routes are verbs too —
54
+ * so every path here comes from the reference, never from a convention. */
55
+ const REDIRECTS = "/seo-redirects-service/v1";
56
+ /** A single SEO tag, in Wix's shape. Left permissive on purpose: which `type`
57
+ * values an endpoint accepts differs per endpoint, and the API rejects the
58
+ * wrong ones with a named error that reaches the model intact. */
59
+ const SeoTagSchema = Type.Object({
60
+ type: Type.String({
61
+ description: "Tag type: `title`, `meta`, `script` or `link`.",
62
+ }),
63
+ props: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
64
+ description: 'Tag properties, e.g. `{"name": "description", "content": "…"}`.',
65
+ })),
66
+ children: Type.Optional(Type.String({ description: "Inner content, e.g. the text of a <title>." })),
67
+ meta: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
68
+ description: 'Tag metadata, e.g. `{"height": 300, "width": 240}`.',
69
+ })),
70
+ custom: Type.Optional(Type.Boolean()),
71
+ disabled: Type.Optional(Type.Boolean()),
72
+ });
73
+ /** One redirect, as Create and Bulk Create accept it. */
74
+ const RedirectSchema = Type.Object({
75
+ from: Type.String({
76
+ description: "Path the redirect starts from, URL-encoded, e.g. `/old-page`. Cannot " +
77
+ "be the site root. Two paths differing only by a trailing slash count " +
78
+ "as the same path.",
79
+ }),
80
+ to: Type.String({
81
+ description: "Where it sends the visitor: a path such as `/new-page`, or a full URL.",
82
+ }),
83
+ options: Type.Optional(Type.Object({
84
+ groupRedirect: Type.Optional(Type.Boolean({
85
+ description: "true = match everything UNDER `from` and carry the rest of the " +
86
+ "URL over. false/omitted = exact path only. A group and an exact " +
87
+ "redirect sharing a `from` path are two different redirects.",
88
+ })),
89
+ })),
90
+ language: Type.Optional(Type.String({
91
+ description: "Language version this applies to (`fr`, `en-US`). Omit to apply to " +
92
+ "every language. A language-scoped path is stored WITHOUT its " +
93
+ "language prefix.",
94
+ })),
95
+ id: Type.Optional(Type.String({
96
+ description: "Redirect GUID. Supply one only to preserve an existing redirect's " +
97
+ "identity while re-creating it; omit for a new redirect.",
98
+ })),
99
+ });
100
+ /** Wix stores a language-scoped redirect WITHOUT its language prefix: a `fr`
101
+ * redirect from `/fr/about` comes back as `/about`. A candidate written with
102
+ * the prefix would be compared against a stored path that never has one, and
103
+ * the conflict this check exists to find would be invisible. */
104
+ function stripLanguagePrefix(path, language) {
105
+ if (language === undefined || language.length === 0)
106
+ return path;
107
+ // `fr` and `fr-CA` both prefix as `/fr/`.
108
+ const code = language.split("-")[0].toLowerCase();
109
+ const lower = path.toLowerCase();
110
+ if (lower === `/${code}`)
111
+ return "/";
112
+ return lower.startsWith(`/${code}/`) ? path.slice(code.length + 1) : path;
113
+ }
114
+ /** Wix treats two paths differing only by a trailing slash as the same path,
115
+ * and matches case-insensitively on the stored path. Comparing raw strings
116
+ * would miss exactly the conflicts this check exists to find. */
117
+ function samePath(a, b) {
118
+ if (a === undefined || b === undefined)
119
+ return false;
120
+ const norm = (p) => p.replace(/\/+$/, "").toLowerCase();
121
+ return norm(a) === norm(b);
122
+ }
123
+ /** Whether two redirects can collide at all.
124
+ *
125
+ * A redirect with no `language` applies to every language; one scoped to a
126
+ * language conflicts only with the same language and with the global ones. */
127
+ function scopesOverlap(a, b) {
128
+ if (a === undefined || a === "" || b === undefined || b === "")
129
+ return true;
130
+ return a.toLowerCase() === b.toLowerCase();
131
+ }
132
+ /**
133
+ * The redirects a create would DESTROY, found before it is sent.
134
+ *
135
+ * Two ways a create removes something, and neither is obvious from the call:
136
+ * - LOOP RESOLUTION: an existing redirect that STARTS AT the path this one
137
+ * points to is deleted so the create can proceed. No flag asks for it, and
138
+ * the API does not report it.
139
+ * - FORCE REPLACE: with the flag, the redirect holding the same `from` path
140
+ * is deleted. Without it, the create fails instead.
141
+ *
142
+ * Leaving this to the tool description made it advice: a direct call, a model
143
+ * that skipped it, or a change between the listing and the write would still
144
+ * lose a redirect nobody named. Run in the execution path, it cannot be
145
+ * skipped — and it reports WHICH redirect, which an approval prompt alone
146
+ * cannot say.
147
+ */
148
+ /** A leading path segment shaped like a language tag: `/fr`, `/pt-br`. Used
149
+ * only to widen what a create is said to endanger, never to narrow it. */
150
+ const LANGUAGE_SEGMENT = /^\/[a-z]{2,3}(?:-[a-z0-9]{2,8})?(?=\/|$)/i;
151
+ function deletionsCausedBy(existing, candidate, forceReplace) {
152
+ const found = [];
153
+ // Compare on what Wix STORES, not on what the caller wrote.
154
+ for (const one of existing) {
155
+ const sameScope = scopesOverlap(one.language, candidate.language);
156
+ // Compare on what Wix STORES — and the prefix to strip belongs to the
157
+ // EXISTING redirect as much as to the candidate. A GLOBAL create pointing
158
+ // at `/fr/blog` aims at the public path of a `fr` redirect that Wix returns
159
+ // as `/blog`: normalising only by the candidate's own language (none, here)
160
+ // left the two looking unrelated, and the deletion went through unannounced.
161
+ const candidateTo = stripLanguagePrefix(stripLanguagePrefix(candidate.to, candidate.language), one.language);
162
+ const candidateFrom = stripLanguagePrefix(stripLanguagePrefix(candidate.from, candidate.language), one.language);
163
+ // SCOPE-BLIND ON `groupRedirect`, DELIBERATELY. An exact and a group
164
+ // redirect sharing a `from` are "two different redirects" upstream, which
165
+ // invites the conclusion that a forceReplace aimed at one spares the other
166
+ // — so this announces a loss Wix might not take. But the rules upstream
167
+ // states for both checks are written on `from` equality alone, with no
168
+ // qualification by scope, and nothing says which of the two a create
169
+ // displaces. Announcing a superset costs one acknowledgement the operator
170
+ // can judge (`groupRedirect` is on every entry); announcing a subset means
171
+ // a permanent deletion nobody was shown. Narrow this only on a documented
172
+ // scope-aware rule or an observed live behaviour, not on the inference.
173
+ // ACROSS LANGUAGES, BUT ONLY WHEN THE CALLER SAID SO. Two languages live in
174
+ // different URL namespaces — a `fr` redirect answers at `/fr/blog`, a `de`
175
+ // one at `/de/blog` — so a create in one language pointing at its own
176
+ // `/blog` cannot loop with the other, and refusing there would block a
177
+ // create that destroys nothing. But a caller can write the OTHER language's
178
+ // prefix explicitly: an `en` redirect pointing at `/fr/blog` names exactly
179
+ // the public path of the `fr` redirect Wix stores as `/blog`. The scope
180
+ // filter alone never reached that comparison, so the deletion went
181
+ // unannounced. This adds that case and takes none away.
182
+ const acrossLanguages = !sameScope &&
183
+ one.language !== undefined &&
184
+ stripLanguagePrefix(candidate.to, one.language) !== candidate.to &&
185
+ samePath(one.from, stripLanguagePrefix(candidate.to, one.language));
186
+ // A GLOBAL REDIRECT ANSWERS UNDER EVERY LANGUAGE. Upstream: omitting
187
+ // `language` applies the redirect to every one of them, so a global rule
188
+ // stored as `/blog` also entered at `/de/blog`. `sameScope` is always true
189
+ // against a global rule, which meant the branch above could never look at a
190
+ // foreign prefix, and a create pointing at `/de/blog` closed a loop with it
191
+ // unannounced. The site's configured languages are not knowable here — the
192
+ // endpoint that lists them needs a different scope and can 403 — so this
193
+ // recognises a leading segment SHAPED like a language tag. It errs towards
194
+ // announcing: a `/fr/...` that is really a content folder costs one
195
+ // acknowledgement, and the operator sees the redirect it names.
196
+ const strippedGuess = candidate.to.replace(LANGUAGE_SEGMENT, "");
197
+ const globalAcrossLanguages = one.language === undefined &&
198
+ strippedGuess !== candidate.to &&
199
+ strippedGuess.length > 0 &&
200
+ samePath(one.from, strippedGuess);
201
+ if ((sameScope && samePath(one.from, candidateTo)) ||
202
+ acrossLanguages ||
203
+ globalAcrossLanguages)
204
+ found.push({ reason: "loop", redirect: one });
205
+ else if (sameScope && forceReplace && samePath(one.from, candidateFrom)) {
206
+ found.push({ reason: "force_replace", redirect: one });
207
+ }
208
+ }
209
+ return found;
210
+ }
211
+ /** A SECOND GAP, same reasoning: whether a create in one language pointing at
212
+ * its own namespace can ever resolve a loop against another language's
213
+ * redirect is not something upstream states, and the two models — compare the
214
+ * stored paths, or compare the public ones — disagree. This compares public
215
+ * paths, adding only the case where the caller wrote the other language's
216
+ * prefix. Settling the rest needs a live create and delete. */
217
+ /** A GAP THIS DOES NOT CLOSE, stated rather than hidden: if an existing GROUP
218
+ * redirect from `/docs` also resolves a loop for a create pointing at
219
+ * `/docs/page` — because a group redirect covers everything under its `from` —
220
+ * no victim is reported. Upstream defines the loop rule as the existing `from`
221
+ * EQUALLING the new `to`, never as covering it, and inventing the wider rule
222
+ * would make every create landing anywhere under a group redirect demand an
223
+ * acknowledgement. Settling it needs a create and a delete against a live site,
224
+ * which this plugin will not do to answer a question. */
225
+ /** Read every redirect on the site. Unpaged by contract — one call is the whole
226
+ * list. */
227
+ async function listExisting(client, siteId, signal) {
228
+ const resp = await client.request("GET", `${REDIRECTS}/redirects`, {
229
+ siteId,
230
+ signal,
231
+ });
232
+ // FAIL CLOSED. `resp?.redirects ?? []` read any unexpected 2xx — a body the
233
+ // client could not parse as JSON, a schema change, a proxy's own page — as
234
+ // "this site has no redirects", and the destructive write went out on the
235
+ // strength of it. The one answer a safety check must never invent is the
236
+ // reassuring one.
237
+ const redirects = resp !== null && typeof resp === "object"
238
+ ? resp.redirects
239
+ : undefined;
240
+ if (!Array.isArray(redirects)) {
241
+ throw new Error("Refusing to write: could not read the site's existing redirects, so " +
242
+ "the check for what this would delete could not run. List Redirects " +
243
+ "returned no `redirects` array.");
244
+ }
245
+ // AND EVERY ELEMENT. Validating only the envelope left the check open to a
246
+ // schema drift — these endpoints are in Developer Preview, so one is a
247
+ // stated possibility rather than a hypothetical: `[{ id, source }]` passes an
248
+ // array test, every `from` reads as undefined, no conflict is ever found, and
249
+ // the destructive write goes out.
250
+ //
251
+ // EVERY FIELD THE TOKEN BINDS, not just `from`. `redirectToken` hashes id,
252
+ // from, to, language and groupRedirect, substituting `""`/`false` for what is
253
+ // absent. Checking only `from` therefore left a drift that renames or drops
254
+ // `to` silently degrading the token into one that no longer changes when the
255
+ // destination does — and an acknowledgement is only a safeguard because it
256
+ // goes stale. The scope fields are checked for SHAPE: an unknown shape is a
257
+ // scope this version cannot reason about.
258
+ for (const one of redirects) {
259
+ const r = one;
260
+ const options = r.options;
261
+ const understood = one !== null &&
262
+ typeof one === "object" &&
263
+ typeof r.id === "string" &&
264
+ typeof r.from === "string" &&
265
+ typeof r.to === "string" &&
266
+ (r.language === undefined || typeof r.language === "string") &&
267
+ (r.options === undefined ||
268
+ // A PLAIN OBJECT CARRYING ONLY WHAT THIS VERSION READS. `options: []`
269
+ // is `typeof "object"` and passed; so did a drift that renamed the flag
270
+ // (`isGroupRedirect`), leaving the redirect described to the operator
271
+ // as exact and its token blind to the scope it really has — the two
272
+ // things the acknowledgement exists to bind.
273
+ (typeof r.options === "object" &&
274
+ r.options !== null &&
275
+ !Array.isArray(r.options) &&
276
+ Object.keys(r.options).every((k) => k === "groupRedirect") &&
277
+ (options?.groupRedirect === undefined ||
278
+ typeof options.groupRedirect === "boolean")));
279
+ if (!understood) {
280
+ throw new Error("Refusing to write: the site's redirect list contains an entry this " +
281
+ "version does not understand (it must carry string `id`, `from` and " +
282
+ "`to`, an optional string `language`, and an optional `options` " +
283
+ "object whose `groupRedirect` is a boolean), so the check for what " +
284
+ "this would delete cannot be trusted.");
285
+ }
286
+ }
287
+ return redirects;
288
+ }
289
+ /**
290
+ * One destructive redirect write per site at a time.
291
+ *
292
+ * The check and the write are two requests. Two concurrent creates can each
293
+ * read a state in which the other's redirect does not exist yet, and the second
294
+ * to land then deletes the first's — a deletion neither caller was shown.
295
+ * Serialising per site removes the race this plugin creates itself.
296
+ *
297
+ * It does NOT make the window disappear: a change made in the Wix dashboard, or
298
+ * by another process, between the read and the write is still possible, and no
299
+ * Wix precondition exists to close it. That residual is stated in the tool
300
+ * descriptions rather than papered over.
301
+ */
302
+ const siteWriteQueues = new Map();
303
+ /** TEST ONLY: how many per-site write chains are still retained.
304
+ *
305
+ * Retention is a COST, not a behaviour — no sequence of tool calls tells a map
306
+ * that grows for ever apart from one that is emptied, so the guard needs a
307
+ * window into the module rather than an assertion about output. */
308
+ export function seoWriteQueueDepth() {
309
+ return siteWriteQueues.size;
310
+ }
311
+ /** `item-seo-tags` pages by CURSOR only. The shared GET envelope also offers
312
+ * `offset`, so the schema accepted — and `buildGetPagingQuery` forwarded — a
313
+ * call mixing the two, which the endpoint does not define. Advertising only
314
+ * what it serves is cheaper than explaining the result of the mixture. */
315
+ const SeoCursorPagingSchema = Type.Object({
316
+ limit: Type.Optional(Type.Number({ minimum: 1, maximum: 1000 })),
317
+ cursor: Type.Optional(Type.String()),
318
+ });
319
+ function serializedBySite(client, siteId, work) {
320
+ // THE EFFECTIVE SITE, resolved before queuing. Keying on the raw argument put
321
+ // an omitted `siteId` and an explicit one naming the same site in two
322
+ // different queues, so the two calls the lock exists to order ran side by
323
+ // side.
324
+ const key = siteId ?? client.defaultSiteId ?? "<default>";
325
+ const previous = siteWriteQueues.get(key) ?? Promise.resolve();
326
+ const next = previous.then(work, work);
327
+ // Keep the chain alive on failure.
328
+ const settled = next.then(() => undefined, () => undefined);
329
+ siteWriteQueues.set(key, settled);
330
+ // AND DROP IT once it is the last one — the comment claimed this before the
331
+ // code did it. The key is a caller-supplied site id, queued BEFORE the
332
+ // whitelist rejects it, so a long-lived gateway retained one settled promise
333
+ // per distinct string ever passed. Deleting only while we are still the tail
334
+ // is what keeps the ordering: if a later call has already chained onto us, it
335
+ // owns the entry and must keep it.
336
+ void settled.then(() => {
337
+ if (siteWriteQueues.get(key) === settled)
338
+ siteWriteQueues.delete(key);
339
+ });
340
+ return next;
341
+ }
342
+ /**
343
+ * A token identifying a redirect AS IT IS RIGHT NOW.
344
+ *
345
+ * Acknowledging a bare GUID was two holes at once: a caller could name an ID
346
+ * on the first call and never be shown what it destroys, and an ID stays valid
347
+ * while the redirect behind it changes — Wix lets a create supply its own GUID,
348
+ * so between the refusal and the retry the same ID can point somewhere else,
349
+ * and the write would delete something the user never saw.
350
+ *
351
+ * Deriving the token from the redirect's identifying fields makes it stale the
352
+ * moment the redirect changes, and the per-process secret makes it impossible
353
+ * to produce without having listed. The EFFECTIVE SITE is part of it because
354
+ * two allowed sites can hold an identical redirect — Wix accepts caller-supplied
355
+ * GUIDs — and a token earned on one would otherwise authorise deleting its twin
356
+ * on the other, on a tenant whose state was never shown.
357
+ */
358
+ function redirectToken(site, one) {
359
+ const canonical = JSON.stringify([
360
+ site,
361
+ one.id ?? "",
362
+ one.from ?? "",
363
+ one.to ?? "",
364
+ one.language ?? "",
365
+ one.options?.groupRedirect === true,
366
+ ]);
367
+ // HMAC with a secret minted once per process, so a token cannot be computed
368
+ // by a caller who never listed. A restart invalidates outstanding tokens,
369
+ // which fails in the safe direction: the call refuses again with a fresh one.
370
+ return `${one.id ?? "no-id"}:${createHmac("sha256", TOKEN_SECRET)
371
+ .update(canonical)
372
+ .digest("hex")
373
+ .slice(0, 16)}`;
374
+ }
375
+ /** What the caller must repeat back to proceed with a destructive create. */
376
+ const AcknowledgeParam = Type.Optional(Type.Array(Type.String(), {
377
+ description: "The `token` values from a previous refusal, for the redirects you " +
378
+ "accept losing. The call is REFUSED, listing them, until every redirect " +
379
+ "it would delete is acknowledged. A token is tied to the redirect's " +
380
+ "CURRENT state: if it changed since the refusal, the token no longer " +
381
+ "matches and the call refuses again with the new one. Show the listed " +
382
+ "redirects to the user and get their answer before filling this in — the " +
383
+ "deletions are permanent.",
384
+ }));
385
+ /**
386
+ * A redirect stripped to the fields this plugin actually supports.
387
+ *
388
+ * TypeBox objects accept unknown properties, so `options.forceReplace` rode
389
+ * through a bulk create untouched — a flag whose whole effect is to delete the
390
+ * redirect holding the `from` path, while the preflight had been run with
391
+ * `forceReplace: false` and the approval prompt never showed it. A schema that
392
+ * merely fails to mention a field does not remove it, and `execute` can be
393
+ * called directly, so the stripping happens here rather than being assumed.
394
+ *
395
+ * `forceReplace` is supported ONLY as the single create's own top-level
396
+ * parameter, where the preflight accounts for it.
397
+ */
398
+ function sanitizeRedirect(one) {
399
+ return {
400
+ from: one.from,
401
+ to: one.to,
402
+ ...(one.options?.groupRedirect === true
403
+ ? { options: { groupRedirect: true } }
404
+ : {}),
405
+ ...(one.language !== undefined ? { language: one.language } : {}),
406
+ ...(one.id !== undefined ? { id: one.id } : {}),
407
+ };
408
+ }
409
+ // ---------------------------------------------------------------------------
410
+ // TRANCHE 2 — writing tags. A DIFFERENT DESTRUCTIVENESS FROM THE REDIRECTS.
411
+ //
412
+ // A redirect delete destroys another row: it can be listed, named, hashed. A
413
+ // tag write destroys THE PREVIOUS VALUE OF WHAT IT WRITES — `tags` replaces the
414
+ // item's set in full. So a caller that sets a title wipes the description that
415
+ // was there, gets exactly what it asked for, and loses something it never
416
+ // mentioned. That is this half's "redirects do not chain".
417
+ //
418
+ // The pre-flight therefore reports what DISAPPEARS — the tags the item holds
419
+ // now whose slot the new set does not fill — never an echo of what is being
420
+ // sent. A caller reading back its own input learns nothing.
421
+ // ---------------------------------------------------------------------------
422
+ /** What a tag occupies, as a human-readable slot: two tags with the same label
423
+ * are the same fact about the page, so one replaces the other. */
424
+ function tagLabel(tag) {
425
+ const type = typeof tag.type === "string" ? tag.type : "?";
426
+ const props = (tag.props ?? {});
427
+ // EVERY DISCRIMINATOR, not the first one found. Stopping at `rel` collapsed
428
+ // every `link rel="alternate"` onto one slot, and `script` had no
429
+ // discriminator at all: a page carrying three JSON-LD blocks or four
430
+ // hreflang alternates would have read as "all kept" when a single tag was
431
+ // sent, and the other two or three would have gone unannounced.
432
+ const parts = ["name", "property", "rel", "httpEquiv", "hreflang", "type", "href"]
433
+ .map((k) => [k, props[k]])
434
+ .filter(([, v]) => typeof v === "string" && v.length > 0)
435
+ .map(([k, v]) => `${k}=${String(v)}`);
436
+ // A `script` is identified by what it carries: two JSON-LD blocks share every
437
+ // property. Hashing the body means EDITING one reads as a loss plus an
438
+ // addition — over-announcing, which is the safe direction here.
439
+ if (type === "script" && typeof tag.children === "string") {
440
+ parts.push(`body=${createHmac("sha256", TOKEN_SECRET).update(tag.children).digest("hex").slice(0, 8)}`);
441
+ }
442
+ // A DISCRIMINATOR THIS VERSION DOES NOT LIST STILL IDENTIFIES A TAG.
443
+ // `meta charset=…`, `meta itemprop=…` and whatever Wix adds next matched
444
+ // none of the keys above, so every one of them collapsed onto the bare
445
+ // `meta` slot — a SINGULAR slot, which reads as an edit, so proposing one
446
+ // replaced another without announcing the loss. A digest of the props that
447
+ // are not the tag's payload keeps them apart. Editing such a tag then reads
448
+ // as a loss plus an addition: over-announcing, the safe direction here.
449
+ if (parts.length === 0) {
450
+ const identity = Object.entries(props)
451
+ .filter(([k, v]) => k !== "content" && typeof v === "string" && v.length > 0)
452
+ .map(([k, v]) => `${k}=${String(v)}`)
453
+ .sort();
454
+ if (identity.length > 0) {
455
+ parts.push(`props=${createHmac("sha256", TOKEN_SECRET)
456
+ .update(identity.join("\u0000"))
457
+ .digest("hex")
458
+ .slice(0, 8)}`);
459
+ }
460
+ }
461
+ // A TAG WITH NO DISCRIMINATOR AT ALL is told apart by what it carries, or
462
+ // not at all. A `meta` holding only `content` matched nothing above and fell
463
+ // back onto the bare `meta` slot, where one silently replaced another.
464
+ // `title` is the one type that is singular BY NATURE, so it keeps its bare
465
+ // slot and an edit of it still reads as an edit.
466
+ if (parts.length === 0 && type !== "title" && typeof props.content === "string") {
467
+ parts.push(`content=${createHmac("sha256", TOKEN_SECRET)
468
+ .update(props.content)
469
+ .digest("hex")
470
+ .slice(0, 8)}`);
471
+ }
472
+ return parts.length > 0 ? `${type}[${parts.join(",")}]` : type;
473
+ }
474
+ /** A slot plus the value it carries, bounded.
475
+ *
476
+ * A LABEL ALONE CANNOT BE DECIDED ON. Editing a `title` in place keeps its
477
+ * slot, so `wouldLose` is empty and the before/after read identically —
478
+ * `title` and `title`. On a PATTERN, that is an operator handing over a token
479
+ * for a rewrite of every page of a type without ever seeing the old template
480
+ * or the new one. The value is what makes the two tell apart. */
481
+ function tagPreview(tag) {
482
+ const t = (tag ?? {});
483
+ const props = (t.props ?? {});
484
+ const raw = typeof t.children === "string"
485
+ ? t.children
486
+ : typeof props.content === "string"
487
+ ? props.content
488
+ : typeof props.href === "string"
489
+ ? props.href
490
+ : undefined;
491
+ // BOUNDED: a JSON-LD block or a long description would otherwise push the
492
+ // rest of the refusal — the part that says what disappears — out of view.
493
+ const value = raw === undefined ? undefined : raw.length > 200 ? `${raw.slice(0, 200)}…` : raw;
494
+ return {
495
+ slot: tagLabel(t),
496
+ ...(value !== undefined ? { value } : {}),
497
+ };
498
+ }
499
+ /** Rebuild a tag from the fields Wix defines, dropping everything else.
500
+ *
501
+ * TypeBox passes undeclared properties through and `execute` is callable
502
+ * directly, so a schema that omits a field does not remove it. `language` in
503
+ * particular must be actively dropped: tags can only be written for the item's
504
+ * primary language, and sending one fails with `LANGUAGE_NOT_SUPPORTED` even
505
+ * when the field mask does not name it. */
506
+ function sanitizeTag(raw, allowedTypes) {
507
+ const t = (raw ?? {});
508
+ const type = typeof t.type === "string" ? t.type : "";
509
+ if (!allowedTypes.includes(type)) {
510
+ throw new Error(`Refusing to write: tag type ${JSON.stringify(type)} is not accepted here. ` +
511
+ `This endpoint accepts ${allowedTypes.map((x) => `\`${x}\``).join(", ")}. ` +
512
+ "Wix reuses one tag shape across APIs but each accepts only some types.");
513
+ }
514
+ // `custom: true` routes the write through the site's Advanced/Custom Tags
515
+ // list, which refuses `title` and `script` with TAG_TYPE_NOT_ALLOWED.
516
+ if (t.custom === true && (type === "title" || type === "script")) {
517
+ throw new Error("Refusing to write: a `title` or `script` tag cannot carry `custom: true` " +
518
+ "— Wix rejects it with TAG_TYPE_NOT_ALLOWED. Omit `custom` for those, " +
519
+ "including for JSON-LD structured data.");
520
+ }
521
+ const out = { type };
522
+ if (t.props !== undefined && t.props !== null && typeof t.props === "object" && !Array.isArray(t.props)) {
523
+ out.props = t.props;
524
+ }
525
+ // `meta` IS PART OF THE WRITABLE SHAPE (e.g. `{height, width}`). Dropping it
526
+ // made every read-modify-write quietly degrade a tag that carried it — and
527
+ // the loss guard could not see it, since a singular slot is matched by slot.
528
+ if (t.meta !== undefined &&
529
+ t.meta !== null &&
530
+ typeof t.meta === "object" &&
531
+ !Array.isArray(t.meta)) {
532
+ out.meta = t.meta;
533
+ }
534
+ if (typeof t.children === "string")
535
+ out.children = t.children;
536
+ if (typeof t.custom === "boolean")
537
+ out.custom = t.custom;
538
+ if (typeof t.disabled === "boolean")
539
+ out.disabled = t.disabled;
540
+ return out;
541
+ }
542
+ /** The tags an item or the site holds RIGHT NOW, hashed like a redirect token:
543
+ * consent is given on a list that was shown, and stops being consent the
544
+ * moment that list changes. */
545
+ function tagsToken(site, scope, current, proposed = []) {
546
+ // THE TOKEN SIGNS THE CHANGE, not just the state. Binding it to the current
547
+ // tags alone let a caller earn a token by proposing to drop the description,
548
+ // then reuse that same token with `tags: []` and take the title too — a loss
549
+ // nobody was ever shown. Proposal included, any edit to what is being written
550
+ // mints a different token and earns a fresh refusal.
551
+ const canonical = JSON.stringify([
552
+ site,
553
+ scope,
554
+ current.map((t) => [tagLabel(t), JSON.stringify(t)]).sort(),
555
+ proposed.map((t) => [tagLabel(t), JSON.stringify(t)]).sort(),
556
+ ]);
557
+ return `${scope}:${createHmac("sha256", TOKEN_SECRET)
558
+ .update(canonical)
559
+ .digest("hex")
560
+ .slice(0, 16)}`;
561
+ }
562
+ /** Tags the item holds now that the incoming set does not replace — the loss
563
+ * nobody asked for. */
564
+ /** What identifies one tag AMONG OTHERS IN THE SAME SLOT: its content. */
565
+ function tagContent(t) {
566
+ // THE WHOLE TAG, not just its text. Two tags sharing a slot and a `content`
567
+ // can still differ by `custom` or `disabled`, and comparing only the text
568
+ // reported the odd one out as kept.
569
+ const tag = (t ?? {});
570
+ const props = (tag.props ?? {});
571
+ return JSON.stringify([
572
+ Object.keys(props)
573
+ .sort()
574
+ .map((k) => [k, props[k]]),
575
+ tag.meta ?? null,
576
+ typeof tag.children === "string" ? tag.children : null,
577
+ tag.custom === true,
578
+ tag.disabled === true,
579
+ ]);
580
+ }
581
+ /** Tags the item holds now that the incoming set does not replace.
582
+ *
583
+ * A slot that appears ONCE is matched by slot alone: replacing a description
584
+ * with another description is an edit, not a loss, and demanding an
585
+ * acknowledgement for every routine rewrite would be ceremony.
586
+ *
587
+ * A slot that REPEATS — two `og:image`, several `article:tag`, several JSON-LD
588
+ * blocks — cannot be matched that way: sending one of them made all of them
589
+ * read as kept, and the rest went out silently. There, a tag survives only if
590
+ * an identical one is being sent. */
591
+ function tagsLostBy(current, next) {
592
+ const incomingBySlot = new Map();
593
+ for (const t of next) {
594
+ const slot = tagLabel(t);
595
+ const list = incomingBySlot.get(slot) ?? [];
596
+ list.push(tagContent(t));
597
+ incomingBySlot.set(slot, list);
598
+ }
599
+ const currentCounts = new Map();
600
+ for (const t of current) {
601
+ const slot = tagLabel(t);
602
+ currentCounts.set(slot, (currentCounts.get(slot) ?? 0) + 1);
603
+ }
604
+ // THE VALUE TRAVELS WITH THE LOSS. A slot alone reads the same whether the
605
+ // description that disappears said one thing or another, and the operator is
606
+ // deciding on the thing, not on its address.
607
+ const lost = [];
608
+ // MATCHES ARE CONSUMED. Two identical tags held today against ONE being sent
609
+ // is a loss of one of them; a set that only tested membership called both
610
+ // kept.
611
+ const unclaimed = new Map([...incomingBySlot].map(([k, v]) => [k, [...v]]));
612
+ for (const t of current) {
613
+ const slot = tagLabel(t);
614
+ const incoming = incomingBySlot.get(slot);
615
+ if (incoming === undefined || incoming.length === 0) {
616
+ lost.push({ ...tagPreview(t), tag: t });
617
+ continue;
618
+ }
619
+ const repeated = (currentCounts.get(slot) ?? 0) > 1 || incoming.length > 1;
620
+ if (!repeated)
621
+ continue;
622
+ const pool = unclaimed.get(slot) ?? [];
623
+ const at = pool.indexOf(tagContent(t));
624
+ if (at === -1) {
625
+ lost.push({ ...tagPreview(t), tag: t });
626
+ }
627
+ else {
628
+ pool.splice(at, 1);
629
+ unclaimed.set(slot, pool);
630
+ }
631
+ }
632
+ return lost;
633
+ }
634
+ /** Read one item's SEO tags, failing closed like `listExisting`. */
635
+ async function readItemOwnTags(client, siteId, itemType, itemId, signal) {
636
+ const resp = await client.request("GET", `/promote/seo/v1/item-seo-tags/${encodeURIComponent(itemType)}/${encodeURIComponent(itemId)}`, { siteId, signal });
637
+ // THE ENVELOPE MUST BE THERE. Falling back to the whole response accepted any
638
+ // 2xx object — `{}` , or a Developer Preview reshuffle — as "this item owns no
639
+ // tags", and a full replacement then went out with nothing to acknowledge.
640
+ // An item with no tags of its own still returns `itemSeoTags`; it is `tags`
641
+ // that is legitimately empty.
642
+ const holder = resp !== null && typeof resp === "object"
643
+ ? resp.itemSeoTags
644
+ : undefined;
645
+ if (holder === null || typeof holder !== "object" || Array.isArray(holder)) {
646
+ throw new Error("Refusing to write: the read of this item's SEO tags did not return the " +
647
+ "`itemSeoTags` object this version understands, so the check for what " +
648
+ "the write would replace cannot be trusted.");
649
+ }
650
+ const tags = holder.tags;
651
+ if (tags !== undefined && !Array.isArray(tags)) {
652
+ throw new Error("Refusing to write: this item's SEO tags could not be read as a list, so " +
653
+ "the check for what the write would replace cannot be trusted.");
654
+ }
655
+ return tags ?? [];
656
+ }
657
+ /** The acknowledgement a tag write takes, mirroring the redirects'. */
658
+ const AcknowledgeReplacementParam = Type.Optional(Type.String({
659
+ description: "The `token` from a previous refusal, once the user has accepted losing " +
660
+ "the tags it listed. Tied to what the item holds RIGHT NOW: if the tags " +
661
+ "changed since the refusal, the token no longer matches and the call is " +
662
+ "refused again with a fresh list.",
663
+ }));
664
+ /** `fieldMask` travels as a COMMA-SEPARATED STRING over REST. The SDK takes an
665
+ * array, which is what a model reaches for; sending one here is silently not
666
+ * the documented shape. Built, never accepted from the caller. */
667
+ function fieldMaskOf(...fields) {
668
+ return fields.filter((f) => typeof f === "string").join(",");
669
+ }
670
+ const SEO_PATTERNS = "/promote/seo/v1/seo-patterns";
671
+ /** The current pattern for a page type, plus HOW MANY PAGES it drives.
672
+ *
673
+ * The count is best-effort on purpose: `pageType` and the item-tags
674
+ * `itemType` are two vocabularies that overlap but are not documented as the
675
+ * same, so a type that lists nothing is reported as `unknown` rather than
676
+ * blocking the write. Refusing on a failed COUNT would be refusing on an
677
+ * inference. */
678
+ async function readPatternPreflight(client, siteId, pageType, pageId, signal) {
679
+ // THE COUNT RUNS FIRST, AND THE PATTERN IS READ LAST. Both orders answer
680
+ // the same, but the pattern read is what the caller's token is computed
681
+ // over: with up to ten listing requests standing between that read and
682
+ // the PATCH, a dashboard edit landing in between was overwritten under a
683
+ // token that had never been shown it. Reading it last leaves no request
684
+ // of ours inside the window.
685
+ // THE COUNT IS A CLAIM, so it says what it does not know.
686
+ // - it follows the cursor instead of reporting the first page as the total;
687
+ // - it excludes items that carry their OWN tags, which a pattern does not
688
+ // drive — counting them overstated the blast radius;
689
+ // - an EMPTY list reads as `unknown`, not `0`: `pageType` and the item-tags
690
+ // `itemType` are two vocabularies that overlap without being documented
691
+ // as the same, so "nothing came back" cannot be told apart from "this
692
+ // type is not listable here";
693
+ // - past the page bound it reports `"100+"` rather than a number it did
694
+ // not finish counting.
695
+ let pageCount = "unknown";
696
+ // UNKNOWN UNTIL COUNTED. Reporting `0` when the listing failed asserted that
697
+ // no page carries tags of its own — a fact the pre-flight never established.
698
+ let partialOverrides = "unknown";
699
+ try {
700
+ let cursor = null;
701
+ let unreadableCursor = false;
702
+ let driven = 0;
703
+ let overridden = 0;
704
+ let seen = 0;
705
+ let rounds = 0;
706
+ do {
707
+ // THE COMPOSITE TYPE for a Wix Data page — `WIX_DATA_PAGE_ITEM-{pageId}`,
708
+ // as this plugin's own `wix_seo_list_item_tags` description already
709
+ // states. Listing the bare type returned nothing, so a dynamic page
710
+ // pattern lost exactly the blast radius the pre-flight exists to show.
711
+ const listType = pageType === "WIX_DATA_PAGE_ITEM" && pageId !== undefined
712
+ ? `${pageType}-${pageId}`
713
+ : pageType;
714
+ const listed = await client.request("GET", `/promote/seo/v1/item-seo-tags/${encodeURIComponent(listType)}`, {
715
+ siteId,
716
+ signal,
717
+ query: compactQuery({
718
+ "paging.limit": 100,
719
+ ...(cursor !== null ? { "paging.cursor": cursor } : {}),
720
+ }),
721
+ });
722
+ const body = (listed ?? {});
723
+ const items = Array.isArray(body.itemsSeoTags) ? body.itemsSeoTags : [];
724
+ seen += items.length;
725
+ // EVERY ITEM COUNTS. `hasOverride` says the item sets SOME tag of its
726
+ // own, not that it escapes the pattern — the tags it does not override
727
+ // still resolve from it. Excluding those pages understated the blast
728
+ // radius, down to zero on a type where every item has a title of its own.
729
+ driven += items.length;
730
+ overridden += items.filter((i) => i.hasOverride === true).length;
731
+ const next = body.pagingMetadata?.cursors?.next;
732
+ // A CURSOR IN A SHAPE THIS VERSION DOES NOT READ IS NOT AN END OF LIST.
733
+ // Coercing it to `null` stopped the walk early and then reported the
734
+ // pages counted so far as an EXACT total — understating the blast
735
+ // radius on the very screen that exists to show it.
736
+ if (next !== undefined && next !== null && typeof next !== "string") {
737
+ unreadableCursor = true;
738
+ break;
739
+ }
740
+ cursor = typeof next === "string" && next.length > 0 ? next : null;
741
+ } while (cursor !== null && ++rounds < 10);
742
+ if (!unreadableCursor && seen > 0) {
743
+ // PAST THE BOUND, BOTH NUMBERS ARE LOWER BOUNDS. Only `pageCount` said
744
+ // so: pages never walked can carry overrides too, so an exact
745
+ // `partialOverrides` understated how many pages keep tags of their own
746
+ // — on the screen that exists to size the blast radius.
747
+ pageCount = cursor !== null ? `${driven}+` : driven;
748
+ partialOverrides = cursor !== null ? `${overridden}+` : overridden;
749
+ }
750
+ }
751
+ catch (err) {
752
+ // AN ABORT IS NOT A FAILED COUNT. Swallowing it let a cancelled call carry
753
+ // on and answer with a refusal, reported as a successful result.
754
+ throwIfAborted(signal);
755
+ if (err instanceof Error && err.name === "AbortError")
756
+ throw err;
757
+ pageCount = "unknown";
758
+ partialOverrides = "unknown";
759
+ }
760
+ const resp = await client.request("GET", `${SEO_PATTERNS}/${encodeURIComponent(pageType)}`, {
761
+ siteId,
762
+ signal,
763
+ // THE RESOURCE THE WRITE WILL TOUCH. `pageId` narrows the pattern to one
764
+ // dynamic page; omitting it here read the page TYPE's generic pattern
765
+ // while the write went to the page's own — so the token, the templates
766
+ // shown and the page count all described something else.
767
+ query: compactQuery({ pageId }),
768
+ });
769
+ // FAIL CLOSED. Falling back to the whole response turned an unrecognised
770
+ // body into "this page type has no pattern" — and for the reset that is the
771
+ // branch that skips the acknowledgement entirely, sending a site-wide reset
772
+ // with nothing shown.
773
+ const holder = resp !== null && typeof resp === "object"
774
+ ? resp.seoPattern
775
+ : undefined;
776
+ if (holder === null || typeof holder !== "object" || Array.isArray(holder)) {
777
+ throw new Error("Refusing to write: the read of this page type's pattern did not return " +
778
+ "the `seoPattern` object this version understands, so the check for " +
779
+ "what the write would replace cannot be trusted.");
780
+ }
781
+ const pattern = holder.pattern;
782
+ // A `pattern` THAT IS THERE BUT UNREADABLE IS NOT AN ABSENT ONE. A string or
783
+ // an array used to fall through to `tags: []`, and the reset — whose whole
784
+ // acknowledgement hangs on `tags.length > 0` — then fired immediately on
785
+ // every page of the type.
786
+ if (pattern !== undefined &&
787
+ (pattern === null || typeof pattern !== "object" || Array.isArray(pattern))) {
788
+ throw new Error("Refusing to write: this page type's `pattern` came back in a shape this " +
789
+ "version does not understand, so it cannot be told apart from a page " +
790
+ "type that has no pattern at all.");
791
+ }
792
+ const rawTags = pattern !== null && pattern !== undefined && typeof pattern === "object"
793
+ ? pattern.tags
794
+ : undefined;
795
+ if (rawTags !== undefined && !Array.isArray(rawTags)) {
796
+ throw new Error("Refusing to write: this page type's pattern could not be read as a list " +
797
+ "of tag templates, so the check for what the write would replace " +
798
+ "cannot be trusted.");
799
+ }
800
+ // OWNERSHIP COMES FROM `source`, NOT FROM A COUNT. A site pattern holding
801
+ // zero templates is still the site's own: inferring absence from
802
+ // `tags.length` made the create recommend a POST that fails
803
+ // PATTERN_ALREADY_EXISTS, and let the reset fire with no acknowledgement.
804
+ const source = holder.source;
805
+ // NO `source`, NO ANSWER. Deducing ownership from the template count made an
806
+ // OWNED but empty pattern read as the Wix default — the branch on which the
807
+ // reset skips its acknowledgement entirely and deletes that pattern.
808
+ if (typeof source !== "string" || source.length === 0) {
809
+ throw new Error("Refusing to write: this page type's pattern came back without a " +
810
+ "`source`, so a pattern the site owns cannot be told apart from the " +
811
+ "Wix default — and the two lead to opposite writes.");
812
+ }
813
+ const hasOwnPattern = source !== "PATTERN_SOURCE_DEFAULT";
814
+ return {
815
+ tags: rawTags ?? [],
816
+ pageCount,
817
+ partialOverrides,
818
+ hasOwnPattern,
819
+ };
820
+ }
821
+ /** The blast radius, as the token sees it.
822
+ *
823
+ * THE RADIUS IS PART OF WHAT WAS ACKNOWLEDGED. Binding only the templates let
824
+ * a token minted while the type held 12 pages authorise the same write once it
825
+ * held 400: the second pre-flight recomputed the count, printed nothing, and
826
+ * wrote. An operator who accepted "this changes 12 pages" never accepted 400.
827
+ * A count that becomes `unknown` expires the token too — fail-closed is the
828
+ * same direction as everywhere else here. */
829
+ function radiusOf(p) {
830
+ return `pages=${p.pageCount}:own=${p.partialOverrides}`;
831
+ }
832
+ export function buildSeoTools(client) {
833
+ return [
834
+ // ---------------------------------------------------------------- audit
835
+ defineWixTool({
836
+ name: "wix_seo_list_item_tags",
837
+ description: "AUDIT ENTRY POINT. List the items of one type with their SEO tags. " +
838
+ "Pass `STATIC_PAGE` to enumerate the site's pages; other types are " +
839
+ "`BLOG_POST`, `STORES_PRODUCT`, and `WIX_DATA_PAGE_ITEM-{pageId}` " +
840
+ "for a page built from a CMS collection. Each item reports " +
841
+ "`hasOverride` (does it set tags of its own), `tags` (those it sets) " +
842
+ "and `resolvedTags` (what it is expected to render, each marked with " +
843
+ "its source: item, host page, user pattern, Wix pattern, or site). " +
844
+ "WIRE SHAPE — a `resolvedTags` entry is an ENVELOPE: the tag is at " +
845
+ "`entry.tag` and its provenance at `entry.source`, whereas `tags` " +
846
+ "holds bare tags with no envelope. Reading `resolvedTags[].type` " +
847
+ "yields nothing, which reads as a missing title rather than as a " +
848
+ "mistake. " +
849
+ "A site without the business solution behind a type returns an empty " +
850
+ "list. Cursor paging: pass the response's cursor back as " +
851
+ "`paging.cursor`. " +
852
+ "LIMIT — for `STATIC_PAGE` the tags reflect the SAVED revision, " +
853
+ "never the published one, and `publishStatus` is always " +
854
+ "`PUBLISH_STATUS_UNSPECIFIED`: report findings as being about the " +
855
+ "saved page, not the live one. `resolvedTags` also omits tags added " +
856
+ "by site code or apps at render time, so it is not the rendered head.",
857
+ parameters: Type.Object({
858
+ siteId: SiteIdParam,
859
+ itemType: Type.String({
860
+ description: "Item type, e.g. `STATIC_PAGE`, `BLOG_POST`, `STORES_PRODUCT`.",
861
+ }),
862
+ paging: Type.Optional(SeoCursorPagingSchema),
863
+ }),
864
+ run: (params, _client, signal) => client.request("GET", `/promote/seo/v1/item-seo-tags/${encodeURIComponent(params.itemType)}`, {
865
+ siteId: params.siteId,
866
+ signal,
867
+ // BUILT FROM THE TWO KEYS THIS ENDPOINT DEFINES, not from the
868
+ // generic helper. Narrowing the schema only stops `offset` being
869
+ // ADVERTISED: TypeBox passes undeclared properties through and
870
+ // `execute` can be called directly, so the helper would still
871
+ // have forwarded a `paging.offset` that arrived at runtime.
872
+ query: compactQuery({
873
+ "paging.limit": params.paging?.limit,
874
+ "paging.cursor": params.paging?.cursor,
875
+ }),
876
+ }),
877
+ }, client),
878
+ defineWixTool({
879
+ name: "wix_seo_get_item_tags",
880
+ description: "Read one item's SEO tags: what it sets itself (`tags`), whether it " +
881
+ "sets anything (`hasOverride`), and what it is expected to render " +
882
+ "(`resolvedTags`, each marked with its source). For a static page, " +
883
+ "`itemId` is the page GUID — get it from `wix_seo_list_item_tags`. " +
884
+ "Same STATIC_PAGE limit: this is the SAVED revision, not the live one.",
885
+ parameters: Type.Object({
886
+ siteId: SiteIdParam,
887
+ itemType: Type.String(),
888
+ itemId: Type.String({
889
+ description: "Item GUID. For a static page, the page GUID.",
890
+ }),
891
+ }),
892
+ run: (params, _client, signal) => client.request("GET", `/promote/seo/v1/item-seo-tags/${encodeURIComponent(params.itemType)}/${encodeURIComponent(params.itemId)}`, { siteId: params.siteId, signal }),
893
+ }, client),
894
+ defineWixTool({
895
+ name: "wix_seo_get_site_tags",
896
+ description: "Read the site-wide SEO tags: the ones that can be changed (`tags` " +
897
+ "— default `og:image`, a `robots` directive, site verification " +
898
+ "tags, other site-wide meta) and the verification tags added " +
899
+ "through the dashboard as HTML embeds (`embedTags`, read-only). " +
900
+ "Per-page tags — titles, descriptions, canonical links, structured " +
901
+ "data — are NOT here: read those with `wix_seo_get_item_tags`.",
902
+ parameters: Type.Object({ siteId: SiteIdParam }),
903
+ run: (params, _client, signal) => client.request("GET", "/promote/seo/v1/site-seo-tags", {
904
+ siteId: params.siteId,
905
+ signal,
906
+ }),
907
+ }, client),
908
+ // ------------------------------------------------------------ redirects
909
+ defineWixTool({
910
+ name: "wix_seo_list_redirects",
911
+ description: "List EVERY redirect on the site in one response. There is no query " +
912
+ "or search endpoint for redirects and no paging — filter and sort " +
913
+ "the returned array yourself. Includes redirects Wix created on the " +
914
+ "owner's behalf, such as when a page's slug was renamed in the " +
915
+ "editor. ALWAYS call this before creating a redirect: a create can " +
916
+ "delete an existing one (see `wix_seo_create_redirect`).",
917
+ parameters: Type.Object({ siteId: SiteIdParam }),
918
+ run: (params, _client, signal) => client.request("GET", `${REDIRECTS}/redirects`, {
919
+ siteId: params.siteId,
920
+ signal,
921
+ }),
922
+ }, client),
923
+ defineWixTool({
924
+ name: "wix_seo_get_redirect",
925
+ description: "Retrieve one redirect by GUID. A 404 means no redirect has that " +
926
+ "GUID — the API does not return the documented `REDIRECT_NOT_FOUND` " +
927
+ "code today, so do not report the GUID as valid.",
928
+ parameters: Type.Object({
929
+ siteId: SiteIdParam,
930
+ redirectId: Type.String({ description: "Redirect GUID." }),
931
+ }),
932
+ run: (params, _client, signal) => client.request("GET", `${REDIRECTS}/redirects/${encodeURIComponent(params.redirectId)}`, { siteId: params.siteId, signal }),
933
+ }, client),
934
+ defineWixTool({
935
+ name: "wix_seo_create_redirect",
936
+ description: "Create a 301 redirect. It takes effect on the live site " +
937
+ "immediately, with no publish, and TAKES PRECEDENCE OVER A REAL " +
938
+ "PAGE at the same path — creating one from a path that still serves " +
939
+ "a page makes that page unreachable. " +
940
+ "THIS CALL CAN DELETE ANOTHER REDIRECT. Redirects do not chain: if " +
941
+ "an existing redirect STARTS AT the path this one points to, that " +
942
+ "existing redirect is deleted and the create proceeds. With this " +
943
+ "tool's OWN top-level `forceReplace` parameter, a redirect holding " +
944
+ "the same `from` path is deleted too; without it, a taken `from` " +
945
+ "path fails with `FROM_URL_EXISTS` and writes nothing. Wix documents " +
946
+ "that flag as `options.forceReplace`, nested in the redirect — DO " +
947
+ "NOT send it there: this tool rebuilds the redirect from the fields " +
948
+ "it supports, so a nested flag is dropped and the create fails as if " +
949
+ "you had never set it. Deletions here are permanent. " +
950
+ "THE CHECK IS RUN FOR YOU: the tool lists the site's redirects first " +
951
+ "and REFUSES, naming what it would delete, until you call again with " +
952
+ "`acknowledgeDeletions` listing the `token` of each — the redirect's " +
953
+ "GUID is NOT accepted, since an id stays valid while the redirect " +
954
+ "behind it changes. Show them to the user and get an answer before " +
955
+ "doing that. " +
956
+ "Never set `forceReplace` on your own initiative. " +
957
+ "There is no update method: to change a redirect, get it, delete it, " +
958
+ "then create it with EVERY field carried over — an omitted field is " +
959
+ "filled with its default, not its previous value.",
960
+ parameters: Type.Object({
961
+ siteId: SiteIdParam,
962
+ redirect: RedirectSchema,
963
+ forceReplace: Type.Optional(Type.Boolean({
964
+ description: "Delete the redirect already holding the `from` path and put " +
965
+ "this one in its place. The replaced redirect is gone for " +
966
+ "good. Only ever set this when the user asked for it after " +
967
+ "being told what it destroys.",
968
+ })),
969
+ acknowledgeDeletions: AcknowledgeParam,
970
+ }),
971
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
972
+ // ABORT IS CHECKED AFTER THE QUEUE, not only before it. A call can
973
+ // sit behind another site-write for as long as that one takes, and
974
+ // running it once the caller has given up sends an irreversible
975
+ // POST for an operation the runtime already reported as cancelled.
976
+ throwIfAborted(signal);
977
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
978
+ // THE PREFLIGHT IS THE CODE'S, not the model's.
979
+ const existing = await listExisting(client, params.siteId, signal);
980
+ // NORMALISED ONCE, then used for BOTH the check and the body. The
981
+ // preflight tested `=== true` while the body forwarded whatever was
982
+ // passed, so a truthy non-boolean — reachable through a direct
983
+ // `execute`, which the schema does not police — ran the check as
984
+ // "no force replace" and then serialised to `true` on the wire.
985
+ // The flag decides a permanent deletion; it cannot mean two things.
986
+ const forceReplace = params.forceReplace === true;
987
+ const doomed = deletionsCausedBy(existing, params.redirect, forceReplace);
988
+ const acknowledged = new Set(params.acknowledgeDeletions ?? []);
989
+ const unacknowledged = doomed.filter((d) => !acknowledged.has(redirectToken(effectiveSite, d.redirect)));
990
+ if (unacknowledged.length > 0) {
991
+ return {
992
+ refused: "this create would permanently delete existing redirect(s)",
993
+ // THE FULL BLAST RADIUS, never the remainder. Listing only what
994
+ // was still unacknowledged made `howToProceed` — "every token
995
+ // above" — name a DIFFERENT set on each refusal: a caller that
996
+ // REPLACED its list instead of extending it alternated between
997
+ // halves and never converged, so a legitimate deletion became
998
+ // impossible. It also understated the loss, the second prompt
999
+ // showing fewer deletions than the first.
1000
+ wouldDelete: doomed.map((d) => ({
1001
+ acknowledged: acknowledged.has(redirectToken(effectiveSite, d.redirect)),
1002
+ reason: d.reason === "loop"
1003
+ ? "redirects do not chain: this one starts at the path your new redirect points to"
1004
+ : "forceReplace: this one holds the `from` path you are taking",
1005
+ // Tied to the redirect's CURRENT state: it stops matching the
1006
+ // moment the redirect changes, so an acknowledgement can
1007
+ // never authorise deleting something else.
1008
+ token: redirectToken(effectiveSite, d.redirect),
1009
+ id: d.redirect.id,
1010
+ from: d.redirect.from,
1011
+ to: d.redirect.to,
1012
+ language: d.redirect.language ?? "all languages",
1013
+ groupRedirect: d.redirect.options?.groupRedirect === true,
1014
+ })),
1015
+ howToProceed: "Show these to the user. Only if they accept the loss, call " +
1016
+ "again with `acknowledgeDeletions` listing EVERY token above, " +
1017
+ "including any already marked `acknowledged: true`. A partial " +
1018
+ "list is refused again.",
1019
+ };
1020
+ }
1021
+ throwIfAborted(signal);
1022
+ return client.request("POST", `${REDIRECTS}/create-redirect`, {
1023
+ siteId: params.siteId,
1024
+ signal,
1025
+ // NOT REPLAYED. A create that Wix applied before answering 502 —
1026
+ // deleting a loop-closing redirect on the way — would be applied
1027
+ // twice by a retry, and nothing in either answer says which
1028
+ // happened. Surfacing the error and letting the caller re-read is
1029
+ // the only honest outcome.
1030
+ retry: false,
1031
+ body: {
1032
+ redirect: sanitizeRedirect(params.redirect),
1033
+ ...(forceReplace ? { options: { forceReplace: true } } : {}),
1034
+ },
1035
+ });
1036
+ }),
1037
+ }, client),
1038
+ defineWixTool({
1039
+ name: "wix_seo_delete_redirect",
1040
+ description: "Delete one redirect by GUID. Permanent, and effective on the live " +
1041
+ "site immediately. The redirect is RE-READ first and the call is " +
1042
+ "REFUSED, showing its paths, language and scope, until you call again " +
1043
+ "with the `token` from that refusal in `acknowledgeDeletions` — a GUID " +
1044
+ "alone is not a target, because Wix accepts caller-supplied GUIDs and " +
1045
+ "the same id can name a different redirect by the time you get here.",
1046
+ parameters: Type.Object({
1047
+ siteId: SiteIdParam,
1048
+ redirectId: Type.String({ description: "Redirect GUID." }),
1049
+ acknowledgeDeletions: AcknowledgeParam,
1050
+ }),
1051
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
1052
+ throwIfAborted(signal);
1053
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
1054
+ // AN ID IS NOT A TARGET. Wix accepts caller-supplied GUIDs, so
1055
+ // between a listing, the operator's approval and this call the same
1056
+ // id can name a different redirect — and the prompt showed nothing
1057
+ // but the id. The redirect is re-read here and must be
1058
+ // acknowledged by a token bound to its current state, the same
1059
+ // protocol the creates use.
1060
+ const existing = await listExisting(client, params.siteId, signal);
1061
+ const target = existing.find((r) => r.id === params.redirectId);
1062
+ if (target === undefined) {
1063
+ return {
1064
+ refused: "no redirect with that id is on this site right now",
1065
+ howToProceed: "Call `wix_seo_list_redirects` and pick the redirect again.",
1066
+ };
1067
+ }
1068
+ const token = redirectToken(effectiveSite, target);
1069
+ if (!(params.acknowledgeDeletions ?? []).includes(token)) {
1070
+ return {
1071
+ refused: "this deletion is permanent and has not been acknowledged",
1072
+ wouldDelete: {
1073
+ token,
1074
+ id: target.id,
1075
+ from: target.from,
1076
+ to: target.to,
1077
+ language: target.language ?? "all languages",
1078
+ groupRedirect: target.options?.groupRedirect === true,
1079
+ },
1080
+ howToProceed: "Show this to the user. Only if they accept the loss, call " +
1081
+ "again with `acknowledgeDeletions: [\"<token>\"]`.",
1082
+ };
1083
+ }
1084
+ throwIfAborted(signal);
1085
+ return client.request("DELETE", `${REDIRECTS}/redirects/${encodeURIComponent(params.redirectId)}`,
1086
+ // NOT REPLAYED. A delete Wix applied before answering 503 comes
1087
+ // back 404 on the replay, and the plugin would report a failure
1088
+ // for a permanent deletion that did happen.
1089
+ { siteId: params.siteId, signal, retry: false });
1090
+ }),
1091
+ }, client),
1092
+ defineWixTool({
1093
+ name: "wix_seo_bulk_create_redirects",
1094
+ description: "Create 1 to 100 redirects in one call. NOT ATOMIC, and each item " +
1095
+ "carries its own outcome INSIDE a successful response: read " +
1096
+ "`results[].itemMetadata`, matched to your request by " +
1097
+ "`originalIndex`; a failed item carries `error.code` such as " +
1098
+ "`FROM_URL_EXISTS`. `bulkActionMetadata.undetailedFailures` counts " +
1099
+ "items whose outcome is UNKNOWN — they may or may not have been " +
1100
+ "written; call `wix_seo_list_redirects` to find out, and never " +
1101
+ "report them as successes. " +
1102
+ "TWO KINDS OF LOOP, and only one of them destroys. An item that " +
1103
+ "closes a loop with an EARLIER ITEM OF THIS SAME REQUEST fails on its " +
1104
+ "own with `REDIRECT_LOOP` and the rest of the batch goes ahead — " +
1105
+ "nothing is deleted, so report that item as failed rather than as a " +
1106
+ "loss. An item that closes a loop with a redirect ALREADY ON THE SITE " +
1107
+ "is created and that site redirect is DELETED, permanently. Only the " +
1108
+ "second is checked for you: the call is REFUSED, naming every existing " +
1109
+ "redirect it would delete and the item causing it, until you call " +
1110
+ "again with `acknowledgeDeletions` listing the `token` of each — a " +
1111
+ "GUID is not accepted. " +
1112
+ "A malformed request (an empty list) is rejected " +
1113
+ "whole with a 400 and returns no per-item results.",
1114
+ parameters: Type.Object({
1115
+ siteId: SiteIdParam,
1116
+ // ONE HUNDRED, from the reference. The skill page says 1–500; the
1117
+ // method page and the Developer Preview index both say 100, and a
1118
+ // batch Wix rejects outright helps nobody.
1119
+ redirects: Type.Array(RedirectSchema, { minItems: 1, maxItems: 100 }),
1120
+ returnFullEntity: Type.Optional(Type.Boolean({
1121
+ description: "Return each created redirect in `results[].item`.",
1122
+ })),
1123
+ acknowledgeDeletions: AcknowledgeParam,
1124
+ }),
1125
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
1126
+ throwIfAborted(signal);
1127
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
1128
+ // Same code-run preflight as the single create: bulk is not atomic,
1129
+ // so a loop-causing redirect can still be deleted before the item
1130
+ // that caused it fails to be written.
1131
+ const existing = await listExisting(client, params.siteId, signal);
1132
+ const acknowledged = new Set(params.acknowledgeDeletions ?? []);
1133
+ const doomed = params.redirects.flatMap((candidate, index) => deletionsCausedBy(existing, candidate, false).map((d) => ({
1134
+ ...d,
1135
+ index,
1136
+ })));
1137
+ const unacknowledged = doomed.filter((d) => !acknowledged.has(redirectToken(effectiveSite, d.redirect)));
1138
+ if (unacknowledged.length > 0) {
1139
+ return {
1140
+ refused: "this bulk create would permanently delete existing redirect(s)",
1141
+ // THE FULL BLAST RADIUS, never the remainder. Listing only what
1142
+ // was still unacknowledged made `howToProceed` — "every token
1143
+ // above" — name a DIFFERENT set on each refusal: a caller that
1144
+ // REPLACED its list instead of extending it alternated between
1145
+ // halves and never converged, so a legitimate deletion became
1146
+ // impossible. It also understated the loss, the second prompt
1147
+ // showing fewer deletions than the first.
1148
+ wouldDelete: doomed.map((d) => ({
1149
+ acknowledged: acknowledged.has(redirectToken(effectiveSite, d.redirect)),
1150
+ causedByItemIndex: d.index,
1151
+ reason: "redirects do not chain: this one starts at the path that item points to",
1152
+ token: redirectToken(effectiveSite, d.redirect),
1153
+ id: d.redirect.id,
1154
+ from: d.redirect.from,
1155
+ to: d.redirect.to,
1156
+ language: d.redirect.language ?? "all languages",
1157
+ groupRedirect: d.redirect.options?.groupRedirect === true,
1158
+ })),
1159
+ howToProceed: "Show these to the user. Only if they accept the loss, call " +
1160
+ "again with `acknowledgeDeletions` listing EVERY token above, " +
1161
+ "including any already marked `acknowledged: true`. A partial " +
1162
+ "list is refused again.",
1163
+ };
1164
+ }
1165
+ throwIfAborted(signal);
1166
+ return client.request("POST", `${REDIRECTS}/bulk/redirects/create`, {
1167
+ siteId: params.siteId,
1168
+ signal,
1169
+ // NOT REPLAYED: bulk create is explicitly non-atomic upstream,
1170
+ // so a partial application followed by a retry is a real path
1171
+ // to duplicated writes and lost redirects.
1172
+ retry: false,
1173
+ body: {
1174
+ redirects: params.redirects.map(sanitizeRedirect),
1175
+ ...(params.returnFullEntity !== undefined
1176
+ ? { returnFullEntity: params.returnFullEntity }
1177
+ : {}),
1178
+ },
1179
+ });
1180
+ }),
1181
+ }, client),
1182
+ defineWixTool({
1183
+ name: "wix_seo_bulk_delete_redirects",
1184
+ description: "Delete 1 to 500 redirects by GUID. Each one is RE-READ first and the " +
1185
+ "call is REFUSED, showing every redirect's paths, language and scope, " +
1186
+ "until the `token` for each comes back in `acknowledgeDeletions` — a " +
1187
+ "GUID alone is not a target. " +
1188
+ "Same per-item reporting as the " +
1189
+ "bulk create: read `results[].itemMetadata` and treat " +
1190
+ "`bulkActionMetadata.undetailedFailures` as unknown, not as " +
1191
+ "success. A successful item means the deletion was found and sent, " +
1192
+ "not confirmed — call `wix_seo_list_redirects` if the user needs " +
1193
+ "confirmation. Permanent.",
1194
+ parameters: Type.Object({
1195
+ siteId: SiteIdParam,
1196
+ redirectIds: Type.Array(Type.String(), {
1197
+ minItems: 1,
1198
+ maxItems: 500,
1199
+ }),
1200
+ acknowledgeDeletions: AcknowledgeParam,
1201
+ }),
1202
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
1203
+ throwIfAborted(signal);
1204
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
1205
+ // THE SAME PROTOCOL AS THE SINGLE DELETE. Sending 500 GUIDs straight
1206
+ // through had exactly the identity hole the single delete just
1207
+ // closed: Wix accepts caller-supplied GUIDs, so an id can name a
1208
+ // different redirect by the time this runs, and the approval prompt
1209
+ // carries ids — not paths, language or scope.
1210
+ const existing = await listExisting(client, params.siteId, signal);
1211
+ const acknowledged = new Set(params.acknowledgeDeletions ?? []);
1212
+ const resolved = params.redirectIds.map((id) => ({
1213
+ id,
1214
+ target: existing.find((r) => r.id === id),
1215
+ }));
1216
+ const missing = resolved.filter((r) => r.target === undefined);
1217
+ const targets = resolved.filter((r) => r.target !== undefined);
1218
+ const unacknowledged = targets.filter((r) => !acknowledged.has(redirectToken(effectiveSite, r.target)));
1219
+ if (missing.length > 0 || unacknowledged.length > 0) {
1220
+ return {
1221
+ refused: "these deletions are permanent and have not all been acknowledged for their CURRENT state",
1222
+ notOnThisSite: missing.map((r) => r.id),
1223
+ // THE FULL BLAST RADIUS, never the remainder. Listing only what
1224
+ // was still unacknowledged made `howToProceed` — "every token
1225
+ // above" — name a DIFFERENT set on each refusal: a caller that
1226
+ // REPLACED its list instead of extending it alternated between
1227
+ // halves and never converged, so a legitimate deletion became
1228
+ // impossible. It also understated the loss, the second prompt
1229
+ // showing fewer deletions than the first.
1230
+ wouldDelete: targets.map((r) => ({
1231
+ acknowledged: acknowledged.has(redirectToken(effectiveSite, r.target)),
1232
+ token: redirectToken(effectiveSite, r.target),
1233
+ id: r.target.id,
1234
+ from: r.target.from,
1235
+ to: r.target.to,
1236
+ language: r.target.language ?? "all languages",
1237
+ groupRedirect: r.target.options?.groupRedirect === true,
1238
+ })),
1239
+ howToProceed: "Show these to the user. Only if they accept the loss, call " +
1240
+ "again with `acknowledgeDeletions` listing EVERY token above, " +
1241
+ "including any already marked `acknowledged: true`. A partial " +
1242
+ "list is refused again. Drop any id listed under " +
1243
+ "`notOnThisSite`.",
1244
+ };
1245
+ }
1246
+ throwIfAborted(signal);
1247
+ return client.request("POST", `${REDIRECTS}/bulk/redirects/delete`, {
1248
+ siteId: params.siteId,
1249
+ signal,
1250
+ // NOT REPLAYED: a partly-applied batch replaced by the replay's own
1251
+ // per-item results hides which deletions actually landed.
1252
+ retry: false,
1253
+ body: { redirectIds: params.redirectIds },
1254
+ });
1255
+ }),
1256
+ }, client),
1257
+ // ---------------------------------------------------- tranche 2: writes
1258
+ defineWixTool({
1259
+ name: "wix_seo_set_item_tags",
1260
+ description: "Set one item's SEO tags. REPLACES the item's own tags IN FULL: send " +
1261
+ "the complete set you want it to have, not just the one you are " +
1262
+ "changing. To give an item back the tags it inherits, call " +
1263
+ "`wix_seo_reset_item_tags` — an empty list is not the way. " +
1264
+ "THE CHECK IS RUN FOR YOU: the tool reads the item first and REFUSES, " +
1265
+ "naming every tag the write would drop, until you call again with " +
1266
+ "`acknowledgeReplacement` set to the `token` from that refusal. " +
1267
+ "PUBLISHED vs SAVED — a static page keeps both. Without `publish` you " +
1268
+ "change the saved revision; with `publish: true` you change ONLY the " +
1269
+ "published one. The read tools always return the SAVED revision, so " +
1270
+ "after a `publish` write they keep showing the old values: that is " +
1271
+ "NOT a failed write and re-sending will not change it. To move both, " +
1272
+ "call twice — once without `publish`, once with. This tool's own " +
1273
+ "response is not a read of the published page either, so never report " +
1274
+ "the live page as changed on the strength of it. " +
1275
+ "Tags are validated before anything is saved: an invalid tag changes " +
1276
+ "nothing. Tags can only be written for the item's primary language.",
1277
+ parameters: Type.Object({
1278
+ siteId: SiteIdParam,
1279
+ itemType: Type.String({
1280
+ description: "e.g. `STATIC_PAGE`, `BLOG_POST`, `STORES_PRODUCT`.",
1281
+ }),
1282
+ itemId: Type.String({ description: "GUID of the item." }),
1283
+ tags: Type.Array(SeoTagSchema, {
1284
+ maxItems: 100,
1285
+ description: "The COMPLETE set of tags this item should own. Accepted types: " +
1286
+ "`title`, `meta`, `script`, `link`" + ".",
1287
+ }),
1288
+ publish: Type.Optional(Type.Boolean({
1289
+ description: "Write the PUBLISHED revision instead of the saved one. Read " +
1290
+ "the tool description before using it.",
1291
+ })),
1292
+ acknowledgeReplacement: AcknowledgeReplacementParam,
1293
+ }),
1294
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
1295
+ throwIfAborted(signal);
1296
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
1297
+ // AN EMPTY LIST IS NOT A REPLACEMENT. Upstream is explicit: to give
1298
+ // an item back what it inherits, call Reset — sending no tags
1299
+ // leaves an empty override behind instead. `minItems` in the schema
1300
+ // does not cover it, since `execute` is callable directly.
1301
+ if (params.tags.length === 0) {
1302
+ return {
1303
+ refused: "an empty tag list does not restore inherited tags, it leaves an empty override",
1304
+ howToProceed: "Call `wix_seo_reset_item_tags` to return this item to the " +
1305
+ "tags it inherits.",
1306
+ };
1307
+ }
1308
+ const next = params.tags.map((t) => sanitizeTag(t, ["title", "meta", "script", "link"]));
1309
+ const current = await readItemOwnTags(client, params.siteId, params.itemType, params.itemId, signal);
1310
+ // THE PUBLISHED REVISION CANNOT BE PRE-READ. `publish: true` writes
1311
+ // the published revision, while every read returns the SAVED one —
1312
+ // Wix exposes no way to read the published tags. So the list below
1313
+ // describes the draft, and a live-only tag would be dropped without
1314
+ // ever appearing in it. A write nobody could be shown is refused
1315
+ // every time, loss detected or not: the one answer a safety check
1316
+ // must never invent is the reassuring one.
1317
+ const blindToLive = params.publish === true;
1318
+ const scope = `item:${params.itemType}:${params.itemId}:${blindToLive ? "published" : "saved"}`;
1319
+ const token = tagsToken(effectiveSite, scope, current, next);
1320
+ const lost = tagsLostBy(current, next);
1321
+ if ((lost.length > 0 || blindToLive) &&
1322
+ params.acknowledgeReplacement !== token) {
1323
+ return {
1324
+ refused: blindToLive
1325
+ ? "this writes the PUBLISHED revision, which cannot be read back — what it replaces there is not knowable, and has not been acknowledged"
1326
+ : "this write would drop tags the item holds today, and they have not been acknowledged for their CURRENT state",
1327
+ ...(blindToLive
1328
+ ? {
1329
+ publishedRevisionUnreadable: "`wouldLose` below is the SAVED revision. If the published page carries tags the draft does not, this write drops them and they are not listed — no API returns them.",
1330
+ }
1331
+ : {}),
1332
+ wouldLose: lost,
1333
+ keeping: next.map((t) => tagPreview(t)),
1334
+ token,
1335
+ howToProceed: "Show what would be lost to the user — these are facts the " +
1336
+ "page states today. Only if they accept, call again with " +
1337
+ "`acknowledgeReplacement` set to the token above, adding any " +
1338
+ "of those tags you meant to keep to `tags`.",
1339
+ };
1340
+ }
1341
+ throwIfAborted(signal);
1342
+ return client.request("PATCH", `/promote/seo/v1/item-seo-tags/${encodeURIComponent(params.itemType)}/${encodeURIComponent(params.itemId)}`, {
1343
+ siteId: params.siteId,
1344
+ signal,
1345
+ // NOT REPLAYED: the pre-flight ran against the state read once;
1346
+ // a replay re-applies a write whose basis may have moved.
1347
+ retry: false,
1348
+ body: {
1349
+ itemSeoTags: { tags: next },
1350
+ // BUILT, NOT ACCEPTED. Over REST the mask is a comma-separated
1351
+ // STRING; the SDK takes an array, which is what a model
1352
+ // reaches for.
1353
+ fieldMask: fieldMaskOf("tags"),
1354
+ ...(params.publish === true ? { publish: true } : {}),
1355
+ },
1356
+ });
1357
+ }),
1358
+ }, client),
1359
+ defineWixTool({
1360
+ name: "wix_seo_reset_item_tags",
1361
+ description: "Give one item back the tags it INHERITS, discarding every tag of its " +
1362
+ "own. All or nothing: there is no way to reset only some of them. " +
1363
+ "The tool reads the item first and REFUSES, listing everything that " +
1364
+ "would go, until you call again with `acknowledgeReplacement` set to " +
1365
+ "the `token` from that refusal. Permanent — the previous tags are not " +
1366
+ "kept anywhere.",
1367
+ parameters: Type.Object({
1368
+ siteId: SiteIdParam,
1369
+ itemType: Type.String(),
1370
+ itemId: Type.String(),
1371
+ acknowledgeReplacement: AcknowledgeReplacementParam,
1372
+ }),
1373
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
1374
+ throwIfAborted(signal);
1375
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
1376
+ const current = await readItemOwnTags(client, params.siteId, params.itemType, params.itemId, signal);
1377
+ const scope = `reset:${params.itemType}:${params.itemId}`;
1378
+ const token = tagsToken(effectiveSite, scope, current);
1379
+ // A TOKEN EVEN THOUGH THE OUTCOME IS A NAMED STATE. The outcome
1380
+ // does not depend on what is there now, but the CONSENT does: the
1381
+ // user accepts losing the list they were shown. If the item's tags
1382
+ // changed in between, that list no longer describes the loss.
1383
+ if (current.length > 0 && params.acknowledgeReplacement !== token) {
1384
+ return {
1385
+ refused: "resetting discards every tag this item owns, and they have not been acknowledged for their CURRENT state",
1386
+ wouldLose: current.map((t) => ({ ...tagPreview(t), tag: t })),
1387
+ token,
1388
+ howToProceed: "Show these to the user. Only if they accept the loss, call " +
1389
+ "again with `acknowledgeReplacement` set to the token above.",
1390
+ };
1391
+ }
1392
+ throwIfAborted(signal);
1393
+ return client.request("POST", `/promote/seo/v1/item-seo-tags/${encodeURIComponent(params.itemType)}/${encodeURIComponent(params.itemId)}/reset-to-default`, { siteId: params.siteId, signal, retry: false });
1394
+ }),
1395
+ }, client),
1396
+ defineWixTool({
1397
+ name: "wix_seo_set_site_tags",
1398
+ description: "Set the SITE-WIDE SEO tags — they apply to every page. REPLACES the " +
1399
+ "site's tags IN FULL, same rule as the per-item write. The tool REFUSES, " +
1400
+ "naming every site-wide tag the write would drop, until you call " +
1401
+ "again with `acknowledgeReplacement` set to the `token` from that " +
1402
+ "refusal. " +
1403
+ "ONLY `meta` TAGS: Wix rejects `title`, `script` and `link` here even " +
1404
+ "though the shared tag shape lists them. There is currently no way to " +
1405
+ "set site-wide structured data through any Wix API — write a per-page " +
1406
+ "`script` tag with `wix_seo_set_item_tags` instead.",
1407
+ parameters: Type.Object({
1408
+ siteId: SiteIdParam,
1409
+ tags: Type.Array(SeoTagSchema, {
1410
+ maxItems: 100,
1411
+ description: "The COMPLETE set of site-wide tags. `meta` only.",
1412
+ }),
1413
+ acknowledgeReplacement: AcknowledgeReplacementParam,
1414
+ }),
1415
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
1416
+ throwIfAborted(signal);
1417
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
1418
+ const next = params.tags.map((t) => sanitizeTag(t, ["meta"]));
1419
+ const resp = await client.request("GET", "/promote/seo/v1/site-seo-tags", { siteId: params.siteId, signal });
1420
+ // Same fail-closed rule as the per-item read: falling back to the
1421
+ // whole response turned `{}` into "the site has no tags", and the
1422
+ // PATCH then replaced every site-wide tag with nothing to
1423
+ // acknowledge.
1424
+ const holder = resp !== null && typeof resp === "object"
1425
+ ? resp.siteSeoTags
1426
+ : undefined;
1427
+ if (holder === null ||
1428
+ typeof holder !== "object" ||
1429
+ Array.isArray(holder)) {
1430
+ throw new Error("Refusing to write: the read of the site's SEO tags did not " +
1431
+ "return the `siteSeoTags` object this version understands, " +
1432
+ "so the check for what the write would replace cannot be " +
1433
+ "trusted.");
1434
+ }
1435
+ const rawTags = holder.tags;
1436
+ if (rawTags !== undefined && !Array.isArray(rawTags)) {
1437
+ throw new Error("Refusing to write: the site's SEO tags could not be read as a " +
1438
+ "list, so the check for what the write would replace cannot " +
1439
+ "be trusted.");
1440
+ }
1441
+ const current = rawTags ?? [];
1442
+ const token = tagsToken(effectiveSite, "site", current, next);
1443
+ const lost = tagsLostBy(current, next);
1444
+ if (lost.length > 0 && params.acknowledgeReplacement !== token) {
1445
+ return {
1446
+ refused: "this write would drop site-wide tags in force today, and they have not been acknowledged for their CURRENT state",
1447
+ wouldLose: lost,
1448
+ keeping: next.map((t) => tagPreview(t)),
1449
+ token,
1450
+ howToProceed: "These apply to EVERY page. Show them to the user and only " +
1451
+ "call again, with `acknowledgeReplacement` set to the token " +
1452
+ "above, once they accept the loss.",
1453
+ };
1454
+ }
1455
+ throwIfAborted(signal);
1456
+ return client.request("PATCH", "/promote/seo/v1/site-seo-tags", {
1457
+ siteId: params.siteId,
1458
+ signal,
1459
+ retry: false,
1460
+ body: {
1461
+ siteSeoTags: { tags: next },
1462
+ fieldMask: fieldMaskOf("tags"),
1463
+ },
1464
+ });
1465
+ }),
1466
+ }, client),
1467
+ defineWixTool({
1468
+ name: "wix_seo_bulk_set_item_tags",
1469
+ description: "Set the SEO tags of MANY items of the same type in one call. Each " +
1470
+ "entry behaves like `wix_seo_set_item_tags`: it REPLACES that item's " +
1471
+ "own tags in full. The tool reads every item first and REFUSES, " +
1472
+ "listing per entry what would be dropped, until you call again with " +
1473
+ "`acknowledgeReplacement` set to the `token` from that refusal. " +
1474
+ "SIZE: up to 100 entries, but `BLOG_POST` is capped at 20 by Wix and " +
1475
+ "exceeding an item type's own limit fails the WHOLE call with " +
1476
+ "`BULK_CHUNK_SIZE_EXCEEDED` — split and retry. " +
1477
+ "PARTIAL SUCCESS: read `results[].itemMetadata` and correlate by " +
1478
+ "`originalIndex`; check `bulkActionMetadata.totalFailures`. One bad " +
1479
+ "entry fails alone. But an unsupported item type, or an unauthorized " +
1480
+ "request, rejects the whole call with no per-entry results — handle " +
1481
+ "both. Re-sending an entry that already succeeded is safe: the writes " +
1482
+ "are idempotent. " +
1483
+ "`publish` is REQUEST-level here: it applies to every entry, and like " +
1484
+ "the single write it moves only the published revision while the read " +
1485
+ "tools keep returning the saved one.",
1486
+ parameters: Type.Object({
1487
+ siteId: SiteIdParam,
1488
+ itemType: Type.String(),
1489
+ entries: Type.Array(Type.Object({
1490
+ itemId: Type.String(),
1491
+ tags: Type.Array(SeoTagSchema, { maxItems: 100 }),
1492
+ }), { minItems: 1, maxItems: 100 }),
1493
+ publish: Type.Optional(Type.Boolean()),
1494
+ acknowledgeReplacement: AcknowledgeReplacementParam,
1495
+ }),
1496
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
1497
+ throwIfAborted(signal);
1498
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
1499
+ // ENFORCED HERE because Wix fails the WHOLE call, not the offending
1500
+ // entries: a 40-post batch would write nothing at all. The cap is
1501
+ // the reference's own (`BLOG_POST` 20), not a guess.
1502
+ const perTypeCap = params.itemType === "BLOG_POST" ? 20 : 100;
1503
+ if (params.entries.length > perTypeCap) {
1504
+ return {
1505
+ refused: `Wix caps a ${params.itemType} bulk write at ${perTypeCap} entries and rejects the whole call beyond it`,
1506
+ entries: params.entries.length,
1507
+ howToProceed: `Split into batches of at most ${perTypeCap} and call again.`,
1508
+ };
1509
+ }
1510
+ // DUPLICATES REFUSED BEFORE THE READS. Two entries for one item are
1511
+ // two FULL replacements of the same tags, both pre-flighted against
1512
+ // the same starting state: the second silently undoes whatever the
1513
+ // first introduced, and neither loss appears in `wouldLose`.
1514
+ const ids = params.entries.map((e) => e.itemId);
1515
+ const duplicated = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))];
1516
+ if (duplicated.length > 0) {
1517
+ return {
1518
+ refused: "the same item appears more than once in this batch, and each entry replaces its tags in full",
1519
+ duplicated,
1520
+ howToProceed: "Merge the entries for each item into one complete set of " +
1521
+ "tags and call again.",
1522
+ };
1523
+ }
1524
+ const empties = params.entries
1525
+ .filter((e) => e.tags.length === 0)
1526
+ .map((e) => e.itemId);
1527
+ if (empties.length > 0) {
1528
+ return {
1529
+ refused: "an empty tag list does not restore inherited tags, it leaves an empty override",
1530
+ entriesWithNoTags: empties,
1531
+ howToProceed: "Drop those entries and call `wix_seo_reset_item_tags` for " +
1532
+ "each item that should go back to what it inherits.",
1533
+ };
1534
+ }
1535
+ const prepared = params.entries.map((e) => ({
1536
+ itemId: e.itemId,
1537
+ next: e.tags.map((t) => sanitizeTag(t, ["title", "meta", "script", "link"])),
1538
+ }));
1539
+ // ONE READ PER ENTRY. There is no bulk read of item tags, and the
1540
+ // loss cannot be shown without knowing what each item holds today.
1541
+ const losses = [];
1542
+ const currentOf = new Map();
1543
+ for (const e of prepared) {
1544
+ throwIfAborted(signal);
1545
+ const current = await readItemOwnTags(client, params.siteId, params.itemType, e.itemId, signal);
1546
+ currentOf.set(e.itemId, current);
1547
+ const lost = tagsLostBy(current, e.next);
1548
+ if (lost.length > 0)
1549
+ losses.push({ itemId: e.itemId, wouldLose: lost });
1550
+ }
1551
+ // Same blindness as the single write, request-wide here.
1552
+ const blindToLive = params.publish === true;
1553
+ const scope = `bulk:${params.itemType}:${blindToLive ? "published" : "saved"}`;
1554
+ const token = tagsToken(effectiveSite, scope,
1555
+ // PAIRED, NOT TWO SORTED LISTS. Signing the proposals apart from
1556
+ // their item ids made A→X/B→Y and A→Y/B→X hash identically, so an
1557
+ // acknowledgement earned for one pairing authorised the other and
1558
+ // replaced tags whose loss was never approved.
1559
+ prepared
1560
+ .map((e) => [e.itemId, currentOf.get(e.itemId) ?? [], e.next])
1561
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0]))), []);
1562
+ if ((losses.length > 0 || blindToLive) &&
1563
+ params.acknowledgeReplacement !== token) {
1564
+ return {
1565
+ refused: blindToLive
1566
+ ? "this batch writes the PUBLISHED revision of every entry, which cannot be read back — what it replaces there is not knowable, and has not been acknowledged"
1567
+ : "this batch would drop tags these items hold today, and they have not been acknowledged for their CURRENT state",
1568
+ ...(blindToLive
1569
+ ? {
1570
+ publishedRevisionUnreadable: "`wouldLose` below is each item's SAVED revision. Tags that exist only on the published page are dropped without being listed.",
1571
+ }
1572
+ : {}),
1573
+ wouldLose: losses,
1574
+ itemsAffected: prepared.length,
1575
+ token,
1576
+ howToProceed: "Show the losses to the user, grouped by item. Only if they " +
1577
+ "accept, call again with `acknowledgeReplacement` set to the " +
1578
+ "token above — the token covers the WHOLE batch and goes " +
1579
+ "stale if any of these items changes.",
1580
+ };
1581
+ }
1582
+ throwIfAborted(signal);
1583
+ return client.request("POST", "/promote/seo/v1/bulk/item-seo-tags/set", {
1584
+ siteId: params.siteId,
1585
+ signal,
1586
+ // NOT REPLAYED: bulk is non-atomic, so a replay's own per-entry
1587
+ // results would hide which of the first attempt's writes landed.
1588
+ retry: false,
1589
+ body: {
1590
+ itemType: params.itemType,
1591
+ entries: prepared.map((e) => ({
1592
+ itemId: e.itemId,
1593
+ itemSeoTags: { tags: e.next },
1594
+ fieldMask: fieldMaskOf("tags"),
1595
+ })),
1596
+ ...(params.publish === true ? { publish: true } : {}),
1597
+ },
1598
+ });
1599
+ }),
1600
+ }, client),
1601
+ // ------------------------------------------------- tranche 2: patterns
1602
+ //
1603
+ // THE WIDEST BLAST RADIUS IN THIS PLUGIN. A pattern is the tag template for
1604
+ // an ENTIRE page type: writing one changes the title of every blog post, or
1605
+ // every product, at once. It also has NO REVISION, and the site's SEO
1606
+ // settings in the Wix dashboard write to the same pattern — last write
1607
+ // wins. Hence: read immediately before writing, bind the token to what was
1608
+ // read, and say how many pages are downstream.
1609
+ defineWixTool({
1610
+ name: "wix_seo_list_patterns",
1611
+ description: "List the site's SEO patterns — the tag templates applied per page " +
1612
+ "type. A page type absent from the list is on its Wix default. " +
1613
+ "Read-only.",
1614
+ parameters: Type.Object({ siteId: SiteIdParam }),
1615
+ run: (params, _client, signal) => client.request("GET", `${SEO_PATTERNS}`, {
1616
+ siteId: params.siteId,
1617
+ signal,
1618
+ }),
1619
+ }, client),
1620
+ defineWixTool({
1621
+ name: "wix_seo_get_pattern",
1622
+ description: "One page type's SEO pattern. Read `source` to know whose it is: " +
1623
+ "`PATTERN_SOURCE_DEFAULT` means the page type renders Wix's default, " +
1624
+ "anything else means the site owns the pattern — an owned pattern " +
1625
+ "may legitimately hold ZERO templates, so an empty `pattern` is not " +
1626
+ "the same as having none. `wix_seo_create_pattern` is for a page type " +
1627
+ "on the Wix default; `wix_seo_set_pattern` for one the site owns. " +
1628
+ "Pass `pageId` to read the pattern of ONE dynamic page rather than " +
1629
+ "the page type's — the writes accept the same targeting, and a write " +
1630
+ "replaces the pattern in full, so this is how you read what to send " +
1631
+ "back. Read-only.",
1632
+ parameters: Type.Object({
1633
+ siteId: SiteIdParam,
1634
+ pageType: Type.String({
1635
+ description: "e.g. `BLOG_POST`, `STORES_PRODUCT`.",
1636
+ }),
1637
+ pageId: Type.Optional(Type.String({
1638
+ description: "GUID of a single dynamic page. Supported for " +
1639
+ "`WIX_DATA_PAGE_ITEM` page types.",
1640
+ })),
1641
+ }),
1642
+ run: (params, _client, signal) => client.request("GET", `${SEO_PATTERNS}/${encodeURIComponent(params.pageType)}`, {
1643
+ siteId: params.siteId,
1644
+ signal,
1645
+ query: compactQuery({ pageId: params.pageId }),
1646
+ }),
1647
+ }, client),
1648
+ defineWixTool({
1649
+ name: "wix_seo_list_pattern_variables",
1650
+ description: "The variables a page type's pattern may reference. A pattern tag " +
1651
+ "that names anything else is invalid — call this BEFORE writing a " +
1652
+ "pattern rather than guessing a variable name. Read-only.",
1653
+ parameters: Type.Object({
1654
+ siteId: SiteIdParam,
1655
+ pageType: Type.String(),
1656
+ }),
1657
+ run: (params, _client, signal) => client.request("GET", `${SEO_PATTERNS}/${encodeURIComponent(params.pageType)}/variables`, { siteId: params.siteId, signal }),
1658
+ }, client),
1659
+ defineWixTool({
1660
+ name: "wix_seo_set_pattern",
1661
+ description: "Change the pattern of a page type that ALREADY HAS ONE. For a page " +
1662
+ "type still on its Wix default, use `wix_seo_create_pattern` — this " +
1663
+ "one changes, it does not create. " +
1664
+ "REPLACES the page type's tag templates IN FULL: read the pattern " +
1665
+ "first and send back the complete set. Wix documents an empty " +
1666
+ "pattern as CLEARING it, returning the page type to the Wix default " +
1667
+ "— but `wix_seo_reset_pattern` is the method that says so plainly, " +
1668
+ "and it is the one to use when that is the intent. " +
1669
+ "THIS CHANGES EVERY PAGE OF THE TYPE AT ONCE. The tool reads the " +
1670
+ "current pattern, reports how many pages are downstream, and REFUSES " +
1671
+ "until you call again with `acknowledgeReplacement` set to the " +
1672
+ "`token` from that refusal. " +
1673
+ "A pattern has no revision and the Wix dashboard writes to the same " +
1674
+ "one: last write wins, so a token that has gone stale means somebody " +
1675
+ "else changed it — re-read before insisting. " +
1676
+ "A tag may reference only the variables `wix_seo_list_pattern_variables` " +
1677
+ "returns for this page type.",
1678
+ parameters: Type.Object({
1679
+ siteId: SiteIdParam,
1680
+ pageType: Type.String(),
1681
+ tags: Type.Array(SeoTagSchema, {
1682
+ maxItems: 100,
1683
+ description: "The COMPLETE set of tag templates for this page type.",
1684
+ }),
1685
+ pageId: Type.Optional(Type.String({
1686
+ description: "GUID of a single dynamic page, when the pattern applies to " +
1687
+ "that page alone rather than to the whole type.",
1688
+ })),
1689
+ acknowledgeReplacement: AcknowledgeReplacementParam,
1690
+ }),
1691
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
1692
+ throwIfAborted(signal);
1693
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
1694
+ const next = params.tags.map((t) => sanitizeTag(t, ["title", "meta", "script", "link"]));
1695
+ const preflight = await readPatternPreflight(client, params.siteId, params.pageType, params.pageId, signal);
1696
+ const token = tagsToken(effectiveSite, `pattern:${params.pageType}:${params.pageId ?? "-"}:${preflight.hasOwnPattern ? "owned" : "default"}:${radiusOf(preflight)}`, preflight.tags, next);
1697
+ const lost = tagsLostBy(preflight.tags, next);
1698
+ // The mirror of the create's check: PATCH changes a pattern that
1699
+ // exists, so on a page type still rendering the Wix default it
1700
+ // cannot do anything but fail — asking for an acknowledgement
1701
+ // first would only make the failure slower.
1702
+ if (!preflight.hasOwnPattern) {
1703
+ return {
1704
+ refused: "this page type is on the Wix default and has no pattern of its own to change",
1705
+ pageType: params.pageType,
1706
+ pagesAffected: preflight.pageCount,
1707
+ howToProceed: "Use `wix_seo_create_pattern` to give it its first pattern.",
1708
+ };
1709
+ }
1710
+ if (params.acknowledgeReplacement !== token) {
1711
+ return {
1712
+ refused: "a pattern applies to every page of its type; this write has not been acknowledged for the pattern's CURRENT state",
1713
+ pageType: params.pageType,
1714
+ pagesAffected: preflight.pageCount,
1715
+ pagesWithSomeTagsOfTheirOwn: preflight.partialOverrides,
1716
+ currentTemplates: preflight.tags.map((t) => tagPreview(t)),
1717
+ wouldLose: lost,
1718
+ keeping: next.map((t) => tagPreview(t)),
1719
+ token,
1720
+ howToProceed: "Tell the user how many pages this changes and what the " +
1721
+ "templates become. Only once they accept, call again with " +
1722
+ "`acknowledgeReplacement` set to the token above.",
1723
+ };
1724
+ }
1725
+ throwIfAborted(signal);
1726
+ return client.request("PATCH", `${SEO_PATTERNS}/${encodeURIComponent(params.pageType)}`, {
1727
+ siteId: params.siteId,
1728
+ signal,
1729
+ retry: false,
1730
+ body: {
1731
+ seoPattern: {
1732
+ pattern: { tags: next },
1733
+ ...(params.pageId !== undefined ? { pageId: params.pageId } : {}),
1734
+ },
1735
+ fieldMask: fieldMaskOf("pattern"),
1736
+ },
1737
+ });
1738
+ }),
1739
+ }, client),
1740
+ defineWixTool({
1741
+ name: "wix_seo_create_pattern",
1742
+ description: "Give a page type its FIRST pattern, replacing the Wix default. If " +
1743
+ "the page type already has one, use `wix_seo_set_pattern` instead — " +
1744
+ "these two share a path and differ only by HTTP method, so the wrong " +
1745
+ "one is an easy mistake. " +
1746
+ "Same blast radius as the set: every page of the type renders from " +
1747
+ "this template. The tool REFUSES, reporting how many pages are " +
1748
+ "downstream, until you call again with `acknowledgeReplacement` set " +
1749
+ "to the `token` from that refusal.",
1750
+ parameters: Type.Object({
1751
+ siteId: SiteIdParam,
1752
+ pageType: Type.String(),
1753
+ tags: Type.Array(SeoTagSchema, { maxItems: 100 }),
1754
+ pageId: Type.Optional(Type.String()),
1755
+ acknowledgeReplacement: AcknowledgeReplacementParam,
1756
+ }),
1757
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
1758
+ throwIfAborted(signal);
1759
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
1760
+ const next = params.tags.map((t) => sanitizeTag(t, ["title", "meta", "script", "link"]));
1761
+ const preflight = await readPatternPreflight(client, params.siteId, params.pageType, params.pageId, signal);
1762
+ const token = tagsToken(effectiveSite, `pattern-create:${params.pageType}:${params.pageId ?? "-"}:${preflight.hasOwnPattern ? "owned" : "default"}:${radiusOf(preflight)}`, preflight.tags, next);
1763
+ // TERMINAL, NOT A TOKEN. Handing one out for a page type that
1764
+ // already owns a pattern only bought a POST Wix answers with
1765
+ // PATTERN_ALREADY_EXISTS.
1766
+ if (preflight.hasOwnPattern) {
1767
+ return {
1768
+ refused: "this page type already has a pattern of its own — create is for a page type still on the Wix default",
1769
+ pageType: params.pageType,
1770
+ pagesAffected: preflight.pageCount,
1771
+ howToProceed: "Use `wix_seo_set_pattern` to change it, or " +
1772
+ "`wix_seo_reset_pattern` to return the page type to the Wix " +
1773
+ "default first.",
1774
+ };
1775
+ }
1776
+ if (params.acknowledgeReplacement !== token) {
1777
+ return {
1778
+ refused: "this pattern will drive every page of its type, and has not been acknowledged",
1779
+ pageType: params.pageType,
1780
+ pagesAffected: preflight.pageCount,
1781
+ pagesWithSomeTagsOfTheirOwn: preflight.partialOverrides,
1782
+ // SHOWN, like the set's. Acknowledging a replacement of
1783
+ // something never displayed is not consent.
1784
+ currentTemplates: preflight.tags.map((t) => tagPreview(t)),
1785
+ proposedTemplates: next.map((t) => tagPreview(t)),
1786
+ token,
1787
+ howToProceed: preflight.hasOwnPattern
1788
+ ? "This page type ALREADY has a pattern — `wix_seo_set_pattern` is the method for that. Confirm with the user before proceeding."
1789
+ : "Tell the user how many pages this changes, then call again with `acknowledgeReplacement` set to the token above.",
1790
+ };
1791
+ }
1792
+ throwIfAborted(signal);
1793
+ return client.request("POST", `${SEO_PATTERNS}/${encodeURIComponent(params.pageType)}`, {
1794
+ siteId: params.siteId,
1795
+ signal,
1796
+ retry: false,
1797
+ body: {
1798
+ seoPattern: {
1799
+ pattern: { tags: next },
1800
+ ...(params.pageId !== undefined ? { pageId: params.pageId } : {}),
1801
+ },
1802
+ },
1803
+ });
1804
+ }),
1805
+ }, client),
1806
+ defineWixTool({
1807
+ name: "wix_seo_reset_pattern",
1808
+ description: "Return a page type to its WIX DEFAULT pattern, discarding the site's " +
1809
+ "own. Every page of the type re-renders from the default. The tool " +
1810
+ "reads the current pattern, reports how many pages are downstream and " +
1811
+ "REFUSES until you call again with `acknowledgeReplacement` set to " +
1812
+ "the `token` from that refusal. Permanent. " +
1813
+ "Pass `pageId` to reset the pattern of ONE dynamic page — without it " +
1814
+ "the reset targets the page TYPE's own pattern, which is a different " +
1815
+ "object and leaves a per-page override in place.",
1816
+ parameters: Type.Object({
1817
+ siteId: SiteIdParam,
1818
+ pageType: Type.String(),
1819
+ pageId: Type.Optional(Type.String({
1820
+ description: "GUID of a single dynamic page whose pattern to reset. " +
1821
+ "`wix_seo_set_pattern` and `wix_seo_create_pattern` can create " +
1822
+ "such an override, so this is how it is undone.",
1823
+ })),
1824
+ acknowledgeReplacement: AcknowledgeReplacementParam,
1825
+ }),
1826
+ run: (params, _client, signal) => serializedBySite(client, params.siteId, async () => {
1827
+ throwIfAborted(signal);
1828
+ const effectiveSite = params.siteId ?? client.defaultSiteId ?? "";
1829
+ const preflight = await readPatternPreflight(client, params.siteId, params.pageType, params.pageId, signal);
1830
+ const token = tagsToken(effectiveSite, `pattern-reset:${params.pageType}:${params.pageId ?? "-"}:${radiusOf(preflight)}`, preflight.tags);
1831
+ if (preflight.hasOwnPattern && params.acknowledgeReplacement !== token) {
1832
+ return {
1833
+ refused: "resetting discards the site's own pattern for this page type, and it has not been acknowledged for its CURRENT state",
1834
+ pageType: params.pageType,
1835
+ pagesAffected: preflight.pageCount,
1836
+ // BOUND, THEREFORE SHOWN. The token covers this number, so a
1837
+ // retry could be refused with a refusal that read identically
1838
+ // — the caller unable to see which half of the radius moved.
1839
+ pagesWithSomeTagsOfTheirOwn: preflight.partialOverrides,
1840
+ wouldLose: preflight.tags.map((t) => ({
1841
+ ...tagPreview(t),
1842
+ tag: t,
1843
+ })),
1844
+ token,
1845
+ howToProceed: "Show the templates that would go and the number of pages " +
1846
+ "affected. Only if the user accepts, call again with " +
1847
+ "`acknowledgeReplacement` set to the token above.",
1848
+ };
1849
+ }
1850
+ throwIfAborted(signal);
1851
+ return client.request("POST", `${SEO_PATTERNS}/${encodeURIComponent(params.pageType)}/reset-to-default`, {
1852
+ siteId: params.siteId,
1853
+ signal,
1854
+ retry: false,
1855
+ // PLACEMENT INFERRED, AND SAID SO. The reference lists
1856
+ // `pageId` as a method parameter of this POST without showing
1857
+ // it on the wire, so the body is the REST convention rather
1858
+ // than a documented fact. It matters: if Wix ignored the field
1859
+ // the reset would hit the page TYPE's pattern instead of the
1860
+ // one page, which is a far wider loss than the refusal
1861
+ // described. Confirming it needs a live create-then-reset on a
1862
+ // throwaway dynamic page, which this plugin will not do to
1863
+ // answer a question.
1864
+ ...(params.pageId !== undefined
1865
+ ? { body: { pageId: params.pageId } }
1866
+ : {}),
1867
+ });
1868
+ }),
1869
+ }, client),
1870
+ ];
1871
+ }
1872
+ // `SeoTagSchema` is exported for the write tools that land in tranche 2
1873
+ // (set item tags, set site tags, SEO patterns).
1874
+ export { SeoTagSchema };
1875
+ //# sourceMappingURL=seo.js.map