@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 +1 -1
- package/src/cms/applySectionConventions.ts +12 -8
- package/src/cms/blockSource.test.ts +1 -1
- package/src/cms/client.browserBundle.test.ts +2 -2
- package/src/cms/client.ts +2 -8
- package/src/cms/index.ts +3 -15
- package/src/cms/loader.test.ts +9 -7
- package/src/cms/loader.ts +10 -14
- package/src/cms/registry.test.ts +24 -16
- package/src/cms/registry.ts +15 -5
- package/src/cms/resolve.test.ts +164 -21
- package/src/cms/resolve.ts +92 -43
- package/src/cms/schema.test.ts +51 -4
- package/src/cms/schema.ts +61 -22
- package/src/cms/sectionMixins.test.ts +9 -13
- package/src/cms/sectionMixins.ts +3 -1
- package/src/cms/draftSource.test.ts +0 -258
- package/src/cms/draftSource.ts +0 -284
package/package.json
CHANGED
|
@@ -6,20 +6,23 @@
|
|
|
6
6
|
* registerSectionsSync, setAsyncRenderingConfig, registerCacheableSections,
|
|
7
7
|
* registerLayoutSections, registerSeoSections, and registerSection.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
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> =
|
|
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 {
|
|
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,
|
package/src/cms/loader.test.ts
CHANGED
|
@@ -82,12 +82,12 @@ describe("matchPath", () => {
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
it("matches with an optional prefix before a literal segment", () => {
|
|
85
|
-
expect(
|
|
86
|
-
"
|
|
87
|
-
});
|
|
88
|
-
expect(
|
|
89
|
-
"
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
206
|
+
pathname: { groups: Record<string, string | undefined> };
|
|
212
207
|
};
|
|
213
208
|
declare const URLPattern: {
|
|
214
|
-
|
|
215
|
-
|
|
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(
|
|
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 " +
|
package/src/cms/registry.test.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
registerSection,
|
|
4
|
+
registerSectionsSync,
|
|
4
5
|
getSection,
|
|
5
6
|
getSectionOptions,
|
|
6
7
|
getSyncComponent,
|
|
7
|
-
|
|
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(
|
|
38
|
-
|
|
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(
|
|
47
|
-
|
|
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(
|
|
56
|
-
|
|
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(
|
|
65
|
-
|
|
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);
|
package/src/cms/registry.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ComponentType } from "react";
|
|
2
2
|
|
|
3
|
-
export type OnBeforeResolveProps = (
|
|
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)
|
|
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 &&
|
|
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(
|
|
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(
|
|
240
|
+
export function getOnBeforeResolveProps(
|
|
241
|
+
sectionKey: string,
|
|
242
|
+
): OnBeforeResolveProps | undefined {
|
|
233
243
|
return onBeforeResolvePropsRegistry[sectionKey];
|
|
234
244
|
}
|
|
235
245
|
|
package/src/cms/resolve.test.ts
CHANGED
|
@@ -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(
|
|
221
|
-
|
|
222
|
-
|
|
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(
|
|
243
|
-
|
|
244
|
-
|
|
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(
|
|
511
|
-
|
|
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);
|