@decocms/blocks 7.28.0-beta.5 → 7.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.28.0-beta.5",
3
+ "version": "7.28.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -6,20 +6,23 @@
6
6
  * registerSectionsSync, setAsyncRenderingConfig, registerCacheableSections,
7
7
  * registerLayoutSections, registerSeoSections, and registerSection.
8
8
  */
9
- import { registerSection, registerSectionsSync } from "./registry";
9
+ import {
10
+ registerSection,
11
+ registerSectionsSync,
12
+ } from "./registry";
13
+ import {
14
+ type CacheableSectionInput,
15
+ registerCacheableSections,
16
+ registerLayoutSections,
17
+ } from "./sectionLoaders";
10
18
  import {
11
19
  type AsyncRenderingConfig,
12
- getAsyncRenderingConfig,
13
20
  registerEagerSections,
14
21
  registerNeverDeferSections,
15
22
  registerSeoSections,
16
23
  setAsyncRenderingConfig,
24
+ getAsyncRenderingConfig,
17
25
  } from "./resolve";
18
- import {
19
- type CacheableSectionInput,
20
- registerCacheableSections,
21
- registerLayoutSections,
22
- } from "./sectionLoaders";
23
26
 
24
27
  export interface SectionMetaEntry {
25
28
  eager?: boolean;
@@ -93,7 +96,8 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo
93
96
  // it, asyncConfig stays null, `useAsync` is false in resolveDecoPage, and
94
97
  // even editor-marked ⚡ sections render eagerly. (The default foldThreshold is
95
98
  // Infinity, so position-based deferral stays off unless a site opts in.)
96
- const existing: Partial<AsyncRenderingConfig> = getAsyncRenderingConfig() ?? {};
99
+ const existing: Partial<AsyncRenderingConfig> =
100
+ getAsyncRenderingConfig() ?? {};
97
101
  setAsyncRenderingConfig({
98
102
  ...existing,
99
103
  alwaysEager: [...(existing.alwaysEager ?? []), ...eagerSections],
@@ -1,5 +1,4 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { djb2Hex } from "../sdk/djb2";
3
2
  import {
4
3
  BundledBlockSource,
5
4
  computeRevision,
@@ -9,6 +8,7 @@ import {
9
8
  revisionKey,
10
9
  snapshotKey,
11
10
  } from "./blockSource";
11
+ import { djb2Hex } from "../sdk/djb2";
12
12
 
13
13
  describe("computeRevision", () => {
14
14
  it("matches loader.ts computeRevision (djb2Hex of JSON.stringify)", () => {
@@ -1,7 +1,7 @@
1
+ import { describe, expect, it } from "vitest";
1
2
  import { execFileSync } from "node:child_process";
2
- import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { describe, expect, it } from "vitest";
4
+ import { dirname, join } from "node:path";
5
5
 
6
6
  const here = dirname(fileURLToPath(import.meta.url));
7
7
  // esbuild's JS API relies on `new TextEncoder().encode("") instanceof
package/src/cms/client.ts CHANGED
@@ -49,6 +49,8 @@ export {
49
49
  registerSectionsSync,
50
50
  setResolvedComponent,
51
51
  } from "./registry";
52
+ export type { SectionLoaderFn } from "./sectionLoaders";
53
+ export { compose, withDevice, withMobile, withSearchParam, withSectionLoader } from "./sectionMixins";
52
54
  export type {
53
55
  ActionConfig,
54
56
  AppSchemas,
@@ -70,11 +72,3 @@ export {
70
72
  registerMatcherSchema,
71
73
  registerMatcherSchemas,
72
74
  } from "./schema";
73
- export type { SectionLoaderFn } from "./sectionLoaders";
74
- export {
75
- compose,
76
- withDevice,
77
- withMobile,
78
- withSearchParam,
79
- withSectionLoader,
80
- } from "./sectionMixins";
package/src/cms/index.ts CHANGED
@@ -12,19 +12,6 @@ export {
12
12
  revisionKey,
13
13
  snapshotKey,
14
14
  } from "./blockSource";
15
- export type { DraftPointer, ResolveDraftOptions } from "./draftSource";
16
- export {
17
- clearDraftCache,
18
- DEFAULT_PREVIEW_API_DOMAINS,
19
- getRequestDraftOverride,
20
- isDraftHostAllowed,
21
- isDraftPreviewEnabled,
22
- parseDraftPointer,
23
- previewApiOriginForHost,
24
- resolveDraftDecofile,
25
- setDraftOverrideGetter,
26
- setDraftPreviewHosts,
27
- } from "./draftSource";
28
15
  export type { DecoPage, Resolvable } from "./loader";
29
16
  export {
30
17
  findPageByPath,
@@ -73,6 +60,7 @@ export {
73
60
  getAsyncRenderingConfig,
74
61
  getDeferredRawProps,
75
62
  isBot,
63
+ isEagerRequest,
76
64
  isSeoSection,
77
65
  onBeforeResolve,
78
66
  reExtractRawProps,
@@ -95,6 +83,8 @@ export {
95
83
  unregisterCommerceLoader,
96
84
  WELL_KNOWN_TYPES,
97
85
  } from "./resolve";
86
+ export type { SectionLoaderContext } from "./sectionLoaderContext";
87
+ export { buildSectionLoaderContext } from "./sectionLoaderContext";
98
88
  export type {
99
89
  ActionConfig,
100
90
  AppSchemas,
@@ -116,8 +106,6 @@ export {
116
106
  registerMatcherSchema,
117
107
  registerMatcherSchemas,
118
108
  } from "./schema";
119
- export type { SectionLoaderContext } from "./sectionLoaderContext";
120
- export { buildSectionLoaderContext } from "./sectionLoaderContext";
121
109
  export type { SectionLoaderFn } from "./sectionLoaders";
122
110
  export {
123
111
  getDegradedSections,
@@ -82,12 +82,12 @@ describe("matchPath", () => {
82
82
  });
83
83
 
84
84
  it("matches with an optional prefix before a literal segment", () => {
85
- expect(matchPath("/{granado/}?campanhas/*", "/granado/campanhas/destaques-2023")).toEqual({
86
- "0": "destaques-2023",
87
- });
88
- expect(matchPath("/{granado/}?campanhas/*", "/campanhas/destaques-2023")).toEqual({
89
- "0": "destaques-2023",
90
- });
85
+ expect(
86
+ matchPath("/{granado/}?campanhas/*", "/granado/campanhas/destaques-2023"),
87
+ ).toEqual({ "0": "destaques-2023" });
88
+ expect(
89
+ matchPath("/{granado/}?campanhas/*", "/campanhas/destaques-2023"),
90
+ ).toEqual({ "0": "destaques-2023" });
91
91
  });
92
92
 
93
93
  it("matches an optional suffix group present and absent", () => {
@@ -112,7 +112,9 @@ describe("matchPath", () => {
112
112
  // biome-ignore lint/performance/noDelete: restoring exact global state
113
113
  delete g.URLPattern;
114
114
  try {
115
- expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow(/URLPattern.*Node\.js >= 24/s);
115
+ expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow(
116
+ /URLPattern.*Node\.js >= 24/s,
117
+ );
116
118
  } finally {
117
119
  if (saved !== undefined) g.URLPattern = saved;
118
120
  }
package/src/cms/loader.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import * as asyncHooks from "node:async_hooks";
2
2
  import { djb2Hex } from "../sdk/djb2";
3
- import { getRequestDraftOverride } from "./draftSource";
4
3
 
5
4
  export type Resolvable = {
6
5
  __resolveType?: string;
@@ -87,8 +86,7 @@ export function setBlocks(blocks: Record<string, unknown>) {
87
86
 
88
87
  /**
89
88
  * Load the current blocks. If running inside a `withBlocksOverride` scope
90
- * (admin preview) or a request carrying a draft-preview override, that
91
- * override is merged on top of the base blocks.
89
+ * (admin preview), the override is merged on top of the base blocks.
92
90
  */
93
91
  export function loadBlocks(): Record<string, unknown> {
94
92
  // Re-sync from globalThis in case setBlocks was called in another module instance
@@ -97,10 +95,7 @@ export function loadBlocks(): Record<string, unknown> {
97
95
  revision = G.__deco.revision ?? null;
98
96
  }
99
97
 
100
- // `withBlocksOverride` (an explicit admin render of a specific payload) wins
101
- // over an ambient draft: the caller named the exact blocks to render, so a
102
- // draft pointer on the same request must not silently replace them.
103
- const override = blocksOverrideStorage.getStore() ?? getRequestDraftOverride();
98
+ const override = blocksOverrideStorage.getStore();
104
99
  if (override) {
105
100
  const merged = { ...blockData };
106
101
  for (const [key, value] of Object.entries(override)) {
@@ -208,14 +203,12 @@ export function getAllPages(): Array<{ key: string; page: DecoPage }> {
208
203
  // same regardless of whether @types/node has its own `URLPattern` global or
209
204
  // not — there's nothing for a local declaration to collide with.
210
205
  type MatchPatternResult = {
211
- pathname: { groups: Record<string, string | undefined> };
206
+ pathname: { groups: Record<string, string | undefined> };
212
207
  };
213
208
  declare const URLPattern: {
214
- new (init: {
215
- pathname: string;
216
- }): {
217
- exec(input: { pathname: string }): MatchPatternResult | null;
218
- };
209
+ new (init: { pathname: string }): {
210
+ exec(input: { pathname: string }): MatchPatternResult | null;
211
+ };
219
212
  };
220
213
 
221
214
  /**
@@ -238,7 +231,10 @@ declare const URLPattern: {
238
231
  * `URLPattern` is native in browsers, workerd, Deno, and Node >= 24 (this
239
232
  * package's `engines` floor). Node 22 and older lack it.
240
233
  */
241
- export function matchPath(pattern: string, urlPath: string): Record<string, string> | null {
234
+ export function matchPath(
235
+ pattern: string,
236
+ urlPath: string,
237
+ ): Record<string, string> | null {
242
238
  if (typeof URLPattern === "undefined") {
243
239
  throw new Error(
244
240
  "@decocms/blocks: this runtime has no URLPattern Web API, so CMS page " +
@@ -1,11 +1,11 @@
1
- import { beforeEach, describe, expect, it } from "vitest";
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
2
  import {
3
- getResolvedComponent,
3
+ registerSection,
4
+ registerSectionsSync,
4
5
  getSection,
5
6
  getSectionOptions,
6
7
  getSyncComponent,
7
- registerSection,
8
- registerSectionsSync,
8
+ getResolvedComponent,
9
9
  } from "./registry";
10
10
 
11
11
  // Reset globalThis.__deco between tests to avoid cross-test pollution
@@ -34,36 +34,44 @@ describe("registerSection + getSection", () => {
34
34
  describe("registerSection with options", () => {
35
35
  it("stores loadingFallback in section options", () => {
36
36
  const fallback = () => null;
37
- registerSection("site/sections/Shelf.tsx", async () => ({ default: () => null }), {
38
- loadingFallback: fallback,
39
- });
37
+ registerSection(
38
+ "site/sections/Shelf.tsx",
39
+ async () => ({ default: () => null }),
40
+ { loadingFallback: fallback },
41
+ );
40
42
 
41
43
  const opts = getSectionOptions("site/sections/Shelf.tsx");
42
44
  expect(opts?.loadingFallback).toBe(fallback);
43
45
  });
44
46
 
45
47
  it("stores clientOnly flag in section options", () => {
46
- registerSection("site/sections/Analytics.tsx", async () => ({ default: () => null }), {
47
- clientOnly: true,
48
- });
48
+ registerSection(
49
+ "site/sections/Analytics.tsx",
50
+ async () => ({ default: () => null }),
51
+ { clientOnly: true },
52
+ );
49
53
 
50
54
  const opts = getSectionOptions("site/sections/Analytics.tsx");
51
55
  expect(opts?.clientOnly).toBe(true);
52
56
  });
53
57
 
54
58
  it("clientOnly defaults to undefined when not set", () => {
55
- registerSection("site/sections/Normal.tsx", async () => ({ default: () => null }), {
56
- loadingFallback: () => null,
57
- });
59
+ registerSection(
60
+ "site/sections/Normal.tsx",
61
+ async () => ({ default: () => null }),
62
+ { loadingFallback: () => null },
63
+ );
58
64
 
59
65
  const opts = getSectionOptions("site/sections/Normal.tsx");
60
66
  expect(opts?.clientOnly).toBeUndefined();
61
67
  });
62
68
 
63
69
  it("clientOnly false is preserved", () => {
64
- registerSection("site/sections/Explicit.tsx", async () => ({ default: () => null }), {
65
- clientOnly: false,
66
- });
70
+ registerSection(
71
+ "site/sections/Explicit.tsx",
72
+ async () => ({ default: () => null }),
73
+ { clientOnly: false },
74
+ );
67
75
 
68
76
  const opts = getSectionOptions("site/sections/Explicit.tsx");
69
77
  expect(opts?.clientOnly).toBe(false);
@@ -1,6 +1,8 @@
1
1
  import type { ComponentType } from "react";
2
2
 
3
- export type OnBeforeResolveProps = (props: Record<string, unknown>) => Record<string, unknown>;
3
+ export type OnBeforeResolveProps = (
4
+ props: Record<string, unknown>,
5
+ ) => Record<string, unknown>;
4
6
 
5
7
  export type SectionModule = {
6
8
  default: ComponentType<any>;
@@ -33,7 +35,8 @@ if (!G.__deco.sectionRegistry) G.__deco.sectionRegistry = {};
33
35
  if (!G.__deco.sectionOptions) G.__deco.sectionOptions = {};
34
36
  if (!G.__deco.resolvedComponents) G.__deco.resolvedComponents = {};
35
37
  if (!G.__deco.syncComponents) G.__deco.syncComponents = {};
36
- if (!G.__deco.onBeforeResolvePropsRegistry) G.__deco.onBeforeResolvePropsRegistry = {};
38
+ if (!G.__deco.onBeforeResolvePropsRegistry)
39
+ G.__deco.onBeforeResolvePropsRegistry = {};
37
40
 
38
41
  const registry: Record<string, RegistryEntry> = G.__deco.sectionRegistry;
39
42
  const sectionOptions: Record<string, SectionOptions> = G.__deco.sectionOptions;
@@ -191,7 +194,9 @@ export function registerSectionsSync(sections: Record<string, SyncSectionEntry>)
191
194
  ];
192
195
  const component =
193
196
  typeof raw === "function" ||
194
- (raw != null && typeof raw === "object" && REACT_WRAPPERS.includes((raw as any).$$typeof))
197
+ (raw != null &&
198
+ typeof raw === "object" &&
199
+ REACT_WRAPPERS.includes((raw as any).$$typeof))
195
200
  ? raw
196
201
  : undefined;
197
202
  if (!component) {
@@ -224,12 +229,17 @@ export function getSyncComponent(key: string): ComponentType<any> | undefined {
224
229
  * BEFORE the resolution engine resolves them. Use to extract metadata from
225
230
  * resolvable structures that would be lost after resolution.
226
231
  */
227
- export function registerOnBeforeResolveProps(sectionKey: string, fn: OnBeforeResolveProps): void {
232
+ export function registerOnBeforeResolveProps(
233
+ sectionKey: string,
234
+ fn: OnBeforeResolveProps,
235
+ ): void {
228
236
  onBeforeResolvePropsRegistry[sectionKey] = fn;
229
237
  }
230
238
 
231
239
  /** Get the registered onBeforeResolveProps for a section, if any. */
232
- export function getOnBeforeResolveProps(sectionKey: string): OnBeforeResolveProps | undefined {
240
+ export function getOnBeforeResolveProps(
241
+ sectionKey: string,
242
+ ): OnBeforeResolveProps | undefined {
233
243
  return onBeforeResolvePropsRegistry[sectionKey];
234
244
  }
235
245
 
@@ -20,10 +20,6 @@ vi.mock("./registry", () => ({
20
20
  getOnBeforeResolveProps: vi.fn(),
21
21
  }));
22
22
 
23
- import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
24
- import { findPageByPath } from "./loader";
25
- import { getSection } from "./registry";
26
- import type { AsyncRenderingConfig, DeferredSection } from "./resolve";
27
23
  import {
28
24
  clearCommerceLoaders,
29
25
  DEFAULT_FOLD_THRESHOLD,
@@ -33,8 +29,8 @@ import {
33
29
  registerCommerceLoader,
34
30
  registerEagerSections,
35
31
  registerNeverDeferSections,
36
- resolveDecoPage,
37
32
  resolveDeferredSectionFull,
33
+ resolveDecoPage,
38
34
  resolvePageSeoBlock,
39
35
  resolveSectionsList,
40
36
  resolveValue,
@@ -43,6 +39,10 @@ import {
43
39
  WELL_KNOWN_TYPES,
44
40
  } from "./resolve";
45
41
  import { runSingleSectionLoader } from "./sectionLoaders";
42
+ import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
43
+ import { findPageByPath } from "./loader";
44
+ import { getSection } from "./registry";
45
+ import type { AsyncRenderingConfig, DeferredSection } from "./resolve";
46
46
 
47
47
  describe("resolveDeferredSectionFull", () => {
48
48
  it("resolves a deferred section and preserves index", async () => {
@@ -217,10 +217,14 @@ describe("commerce loader auto-injects URL search params as props", () => {
217
217
  return null;
218
218
  });
219
219
 
220
- await resolveValue({ __resolveType: KEY, slug: "sabonete" }, undefined, {
221
- url: "https://store.com/produto/sabonete/p?skuId=12345&size=M",
222
- path: "/produto/sabonete/p",
223
- });
220
+ await resolveValue(
221
+ { __resolveType: KEY, slug: "sabonete" },
222
+ undefined,
223
+ {
224
+ url: "https://store.com/produto/sabonete/p?skuId=12345&size=M",
225
+ path: "/produto/sabonete/p",
226
+ },
227
+ );
224
228
 
225
229
  expect(calls).toHaveLength(1);
226
230
  expect(calls[0]).toMatchObject({
@@ -239,10 +243,11 @@ describe("commerce loader auto-injects URL search params as props", () => {
239
243
  return null;
240
244
  });
241
245
 
242
- await resolveValue({ __resolveType: KEY, skuId: "cms-locked-sku" }, undefined, {
243
- url: "https://store.com/p?skuId=url-value",
244
- path: "/p",
245
- });
246
+ await resolveValue(
247
+ { __resolveType: KEY, skuId: "cms-locked-sku" },
248
+ undefined,
249
+ { url: "https://store.com/p?skuId=url-value", path: "/p" },
250
+ );
246
251
 
247
252
  expect(calls[0]?.skuId).toBe("cms-locked-sku");
248
253
  });
@@ -353,10 +358,7 @@ describe("commerce loader resolves legacy .ts-suffixed resolveType", () => {
353
358
  await resolveValue(
354
359
  { __resolveType: "shopify/loaders/ProductDetailsPage.ts", slug: "oversize-t-shirt-123" },
355
360
  undefined,
356
- {
357
- url: "https://store.com/products/oversize-t-shirt-123",
358
- path: "/products/oversize-t-shirt-123",
359
- },
361
+ { url: "https://store.com/products/oversize-t-shirt-123", path: "/products/oversize-t-shirt-123" },
360
362
  );
361
363
 
362
364
  expect(calls).toHaveLength(1);
@@ -507,9 +509,9 @@ describe("isEagerRequest — programmatic fetch detection", () => {
507
509
  });
508
510
 
509
511
  it("falls back to matcherCtx.headers when no Request is present", () => {
510
- expect(isEagerRequest({ userAgent: HUMAN_UA, headers: { "sec-fetch-dest": "empty" } })).toBe(
511
- true,
512
- );
512
+ expect(
513
+ isEagerRequest({ userAgent: HUMAN_UA, headers: { "sec-fetch-dest": "empty" } }),
514
+ ).toBe(true);
513
515
  });
514
516
 
515
517
  it("a request with no Sec-Fetch headers stays deferred (no UA bot, no override)", () => {
@@ -621,6 +623,104 @@ describe("resolvePageSeoBlock — bot-aware commerce SEO", () => {
621
623
  });
622
624
  });
623
625
 
626
+ describe("resolvePageSeoBlock — per-section ignoreStructuredData drives the fetch-skip", () => {
627
+ const KEY = "site/loaders/__test/plpSeoLoader";
628
+ const HUMAN_UA =
629
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36";
630
+ const BOT_UA = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
631
+
632
+ // PLP toggle lives under `configJsonLD`; PDP toggle is top-level.
633
+ const plpBlock = {
634
+ __resolveType: "commerce/sections/Seo/SeoPLPV2.tsx",
635
+ title: "Escolar",
636
+ jsonLD: { __resolveType: KEY },
637
+ configJsonLD: { ignoreStructuredData: true },
638
+ };
639
+ const pdpBlock = {
640
+ __resolveType: "commerce/sections/Seo/SeoPDPV2.tsx",
641
+ title: "Produto",
642
+ jsonLD: { __resolveType: KEY },
643
+ ignoreStructuredData: true,
644
+ };
645
+
646
+ const rctx = (userAgent?: string) =>
647
+ ({
648
+ matcherCtx: { userAgent, url: "https://store.com/escolar", path: "/escolar" },
649
+ memo: new Map(),
650
+ depth: 0,
651
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
652
+ }) as any;
653
+
654
+ beforeEach(() => {
655
+ clearCommerceLoaders();
656
+ // The per-section toggle is the PRIMARY lever — it must work with the
657
+ // site-wide botAwareSeo shortcut OFF.
658
+ setAsyncRenderingConfig({ botAwareSeo: false });
659
+ });
660
+ afterEach(() => {
661
+ clearCommerceLoaders();
662
+ setAsyncRenderingConfig({ botAwareSeo: false });
663
+ });
664
+
665
+ it("humans: toggle skips the commerce loader (PLP configJsonLD)", async () => {
666
+ let calls = 0;
667
+ registerCommerceLoader(KEY, async () => {
668
+ calls++;
669
+ return { seo: { title: "Rich SEO title" }, products: [{ id: 1 }] };
670
+ });
671
+
672
+ const res = await resolvePageSeoBlock(plpBlock, rctx(HUMAN_UA));
673
+
674
+ expect(calls).toBe(0); // no product fetch for humans
675
+ expect(res?.props).toHaveProperty("title", "Escolar");
676
+ expect(res?.props).not.toHaveProperty("jsonLD");
677
+ });
678
+
679
+ it("bots: toggle still resolves the commerce loader (PLP configJsonLD)", async () => {
680
+ let calls = 0;
681
+ registerCommerceLoader(KEY, async () => {
682
+ calls++;
683
+ return { seo: { title: "Rich SEO title" }, products: [{ id: 1 }] };
684
+ });
685
+
686
+ const res = await resolvePageSeoBlock(plpBlock, rctx(BOT_UA));
687
+
688
+ expect(calls).toBe(1);
689
+ expect(res?.props?.jsonLD).toMatchObject({ seo: { title: "Rich SEO title" } });
690
+ });
691
+
692
+ it("humans: top-level toggle skips the commerce loader (PDP)", async () => {
693
+ let calls = 0;
694
+ registerCommerceLoader(KEY, async () => {
695
+ calls++;
696
+ return { seo: { title: "Rich SEO title" }, product: { id: 1 } };
697
+ });
698
+
699
+ const res = await resolvePageSeoBlock(pdpBlock, rctx(HUMAN_UA));
700
+
701
+ expect(calls).toBe(0);
702
+ expect(res?.props).not.toHaveProperty("jsonLD");
703
+ });
704
+
705
+ it("toggle OFF: humans keep the full SEO even with botAwareSeo off (no regression)", async () => {
706
+ let calls = 0;
707
+ registerCommerceLoader(KEY, async () => {
708
+ calls++;
709
+ return { seo: { title: "Rich SEO title" }, products: [{ id: 1 }] };
710
+ });
711
+
712
+ const noToggle = {
713
+ __resolveType: "commerce/sections/Seo/SeoPLPV2.tsx",
714
+ title: "Escolar",
715
+ jsonLD: { __resolveType: KEY },
716
+ };
717
+ const res = await resolvePageSeoBlock(noToggle, rctx(HUMAN_UA));
718
+
719
+ expect(calls).toBe(1); // no toggle + no global flag → resolve for everyone
720
+ expect(res?.props?.jsonLD).toMatchObject({ seo: { title: "Rich SEO title" } });
721
+ });
722
+ });
723
+
624
724
  // ---------------------------------------------------------------------------
625
725
  // resolveDecoPage — #277 client-side navigation disables deferral
626
726
  // ---------------------------------------------------------------------------
@@ -646,6 +746,20 @@ describe("extractSeoFromProps — commerce jsonLD structured data", () => {
646
746
  ...overrides,
647
747
  });
648
748
 
749
+ const pdp = (overrides: Record<string, unknown> = {}) => ({
750
+ "@type": "ProductDetailsPage",
751
+ breadcrumbList: {
752
+ "@type": "BreadcrumbList",
753
+ itemListElement: [
754
+ { "@type": "ListItem", position: 1, item: "https://x.com/a" },
755
+ { "@type": "ListItem", position: 2, item: "https://x.com/a/p" },
756
+ ],
757
+ },
758
+ product: { "@type": "Product", name: "P1", image: [{ url: "https://x.com/p.jpg" }] },
759
+ seo: { title: "PDP Title", description: "PDP Desc" },
760
+ ...overrides,
761
+ });
762
+
649
763
  it("derives title/description/canonical and emits the ItemList JSON-LD (PLP)", () => {
650
764
  // The casaevideo.com.br/eletroportateis regression: a page.seo pointing at
651
765
  // commerce/sections/Seo/SeoPLPV2.tsx resolved its jsonLD but emitted no
@@ -693,7 +807,7 @@ describe("extractSeoFromProps — commerce jsonLD structured data", () => {
693
807
  expect(seo.jsonLDs).toHaveLength(1);
694
808
  });
695
809
 
696
- it("omits structured data when ignoreStructuredData is set", () => {
810
+ it("omits structured data for humans when ignoreStructuredData is set", () => {
697
811
  const seo = extractSeoFromProps({
698
812
  jsonLD: plp(),
699
813
  configJsonLD: { ignoreStructuredData: true },
@@ -703,6 +817,35 @@ describe("extractSeoFromProps — commerce jsonLD structured data", () => {
703
817
  expect(seo.title).toBe("PLP Title");
704
818
  });
705
819
 
820
+ it("keeps structured data for bots even when ignoreStructuredData is set", () => {
821
+ // Bot-aware: the toggle suppresses JSON-LD for humans only. Crawlers (and the
822
+ // `?__deco_ssr=1` audit override, both surfaced via isEager) still get it.
823
+ const seo = extractSeoFromProps(
824
+ { jsonLD: plp(), configJsonLD: { ignoreStructuredData: true } },
825
+ { isEager: true },
826
+ );
827
+ expect(seo.jsonLDs).toHaveLength(1);
828
+ expect(seo.title).toBe("PLP Title");
829
+ });
830
+
831
+ it("omits structured data for bots too when the listing is empty", () => {
832
+ // ignoreStructuredData is bot-aware, but an empty listing contributes no
833
+ // ItemList regardless of bot status.
834
+ const seo = extractSeoFromProps(
835
+ { jsonLD: plp({ products: [] }), configJsonLD: { ignoreStructuredData: true } },
836
+ { isEager: true },
837
+ );
838
+ expect(seo.jsonLDs).toBeUndefined();
839
+ });
840
+
841
+ it("keeps PDP structured data for bots with top-level ignoreStructuredData", () => {
842
+ const seo = extractSeoFromProps(
843
+ { jsonLD: pdp(), ignoreStructuredData: true },
844
+ { isEager: true },
845
+ );
846
+ expect(seo.jsonLDs).toHaveLength(1);
847
+ });
848
+
706
849
  it("marks an empty listing noIndexing and emits no ItemList", () => {
707
850
  const seo = extractSeoFromProps({ jsonLD: plp({ products: [] }) });
708
851
  expect(seo.noIndexing).toBe(true);