@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/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