@lacneu/wix-openclaw 0.3.1 → 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 +95 -2
- package/README.md +25 -10
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +238 -0
- package/dist/index.js +16 -1
- package/dist/index.js.map +1 -1
- package/dist/tools/seo.d.ts +239 -0
- package/dist/tools/seo.js +1039 -0
- package/dist/tools/seo.js.map +1 -1
- package/openclaw.plugin.json +22 -5
- package/package.json +1 -1
package/dist/tools/seo.js
CHANGED
|
@@ -64,6 +64,9 @@ const SeoTagSchema = Type.Object({
|
|
|
64
64
|
description: 'Tag properties, e.g. `{"name": "description", "content": "…"}`.',
|
|
65
65
|
})),
|
|
66
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
|
+
})),
|
|
67
70
|
custom: Type.Optional(Type.Boolean()),
|
|
68
71
|
disabled: Type.Optional(Type.Boolean()),
|
|
69
72
|
});
|
|
@@ -403,6 +406,429 @@ function sanitizeRedirect(one) {
|
|
|
403
406
|
...(one.id !== undefined ? { id: one.id } : {}),
|
|
404
407
|
};
|
|
405
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
|
+
}
|
|
406
832
|
export function buildSeoTools(client) {
|
|
407
833
|
return [
|
|
408
834
|
// ---------------------------------------------------------------- audit
|
|
@@ -828,6 +1254,619 @@ export function buildSeoTools(client) {
|
|
|
828
1254
|
});
|
|
829
1255
|
}),
|
|
830
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),
|
|
831
1870
|
];
|
|
832
1871
|
}
|
|
833
1872
|
// `SeoTagSchema` is exported for the write tools that land in tranche 2
|