@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.
- package/CHANGELOG.md +510 -1
- package/README.md +109 -8
- package/dist/config.d.ts +1 -0
- package/dist/config.js +25 -1
- package/dist/config.js.map +1 -1
- package/dist/hooks/approval.d.ts +10 -0
- package/dist/hooks/approval.js +301 -18
- package/dist/hooks/approval.js.map +1 -1
- package/dist/index.d.ts +1586 -8
- package/dist/index.js +63 -19
- package/dist/index.js.map +1 -1
- package/dist/tools/_factory.d.ts +26 -2
- package/dist/tools/_factory.js.map +1 -1
- package/dist/tools/blog.d.ts +11 -11
- package/dist/tools/bookings.d.ts +5 -5
- package/dist/tools/contacts.d.ts +5 -5
- package/dist/tools/data.d.ts +5 -5
- package/dist/tools/design.d.ts +1 -1
- package/dist/tools/events.d.ts +3 -3
- package/dist/tools/faq.d.ts +5 -5
- package/dist/tools/forms.d.ts +2 -2
- package/dist/tools/media.d.ts +2 -2
- package/dist/tools/multilingual.d.ts +2 -2
- package/dist/tools/reviews.d.ts +3 -3
- package/dist/tools/seo.d.ts +438 -0
- package/dist/tools/seo.js +1875 -0
- package/dist/tools/seo.js.map +1 -0
- package/dist/tools/site.d.ts +2 -2
- package/dist/types.d.ts +11 -0
- package/dist/wix-client.js +30 -4
- package/dist/wix-client.js.map +1 -1
- package/openclaw.plugin.json +57 -6
- package/package.json +1 -1
|
@@ -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
|