@decocms/blocks 7.28.1 → 8.0.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/package.json +1 -1
- package/src/cms/applySectionConventions.ts +8 -12
- package/src/cms/blockSource.test.ts +1 -1
- package/src/cms/client.browserBundle.test.ts +2 -2
- package/src/cms/client.ts +8 -2
- package/src/cms/draftSource.test.ts +343 -0
- package/src/cms/draftSource.ts +362 -0
- package/src/cms/index.ts +23 -2
- package/src/cms/loader.test.ts +7 -9
- package/src/cms/loader.ts +14 -10
- package/src/cms/registry.test.ts +16 -24
- package/src/cms/registry.ts +5 -15
- package/src/cms/resolve.test.ts +20 -22
- package/src/cms/resolve.ts +14 -16
- package/src/cms/schema.ts +1 -4
- package/src/cms/sectionMixins.test.ts +13 -9
- package/src/cms/sectionMixins.ts +1 -3
package/package.json
CHANGED
|
@@ -6,23 +6,20 @@
|
|
|
6
6
|
* registerSectionsSync, setAsyncRenderingConfig, registerCacheableSections,
|
|
7
7
|
* registerLayoutSections, registerSeoSections, and registerSection.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
registerSection,
|
|
11
|
-
registerSectionsSync,
|
|
12
|
-
} from "./registry";
|
|
13
|
-
import {
|
|
14
|
-
type CacheableSectionInput,
|
|
15
|
-
registerCacheableSections,
|
|
16
|
-
registerLayoutSections,
|
|
17
|
-
} from "./sectionLoaders";
|
|
9
|
+
import { registerSection, registerSectionsSync } from "./registry";
|
|
18
10
|
import {
|
|
19
11
|
type AsyncRenderingConfig,
|
|
12
|
+
getAsyncRenderingConfig,
|
|
20
13
|
registerEagerSections,
|
|
21
14
|
registerNeverDeferSections,
|
|
22
15
|
registerSeoSections,
|
|
23
16
|
setAsyncRenderingConfig,
|
|
24
|
-
getAsyncRenderingConfig,
|
|
25
17
|
} from "./resolve";
|
|
18
|
+
import {
|
|
19
|
+
type CacheableSectionInput,
|
|
20
|
+
registerCacheableSections,
|
|
21
|
+
registerLayoutSections,
|
|
22
|
+
} from "./sectionLoaders";
|
|
26
23
|
|
|
27
24
|
export interface SectionMetaEntry {
|
|
28
25
|
eager?: boolean;
|
|
@@ -96,8 +93,7 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo
|
|
|
96
93
|
// it, asyncConfig stays null, `useAsync` is false in resolveDecoPage, and
|
|
97
94
|
// even editor-marked ⚡ sections render eagerly. (The default foldThreshold is
|
|
98
95
|
// Infinity, so position-based deferral stays off unless a site opts in.)
|
|
99
|
-
const existing: Partial<AsyncRenderingConfig> =
|
|
100
|
-
getAsyncRenderingConfig() ?? {};
|
|
96
|
+
const existing: Partial<AsyncRenderingConfig> = getAsyncRenderingConfig() ?? {};
|
|
101
97
|
setAsyncRenderingConfig({
|
|
102
98
|
...existing,
|
|
103
99
|
alwaysEager: [...(existing.alwaysEager ?? []), ...eagerSections],
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { djb2Hex } from "../sdk/djb2";
|
|
2
3
|
import {
|
|
3
4
|
BundledBlockSource,
|
|
4
5
|
computeRevision,
|
|
@@ -8,7 +9,6 @@ import {
|
|
|
8
9
|
revisionKey,
|
|
9
10
|
snapshotKey,
|
|
10
11
|
} 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";
|
|
2
1
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
2
|
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
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,8 +49,6 @@ 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";
|
|
54
52
|
export type {
|
|
55
53
|
ActionConfig,
|
|
56
54
|
AppSchemas,
|
|
@@ -72,3 +70,11 @@ export {
|
|
|
72
70
|
registerMatcherSchema,
|
|
73
71
|
registerMatcherSchemas,
|
|
74
72
|
} from "./schema";
|
|
73
|
+
export type { SectionLoaderFn } from "./sectionLoaders";
|
|
74
|
+
export {
|
|
75
|
+
compose,
|
|
76
|
+
withDevice,
|
|
77
|
+
withMobile,
|
|
78
|
+
withSearchParam,
|
|
79
|
+
withSectionLoader,
|
|
80
|
+
} from "./sectionMixins";
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
clearDraftCache,
|
|
5
|
+
DEFAULT_PREVIEW_API_DOMAINS,
|
|
6
|
+
draftPointerFromRequest,
|
|
7
|
+
isDraftHostAllowed,
|
|
8
|
+
isDraftPreviewEnabled,
|
|
9
|
+
parseDraftPointer,
|
|
10
|
+
previewApiOriginForHost,
|
|
11
|
+
resolveDraftDecofile,
|
|
12
|
+
resolveDraftForRequest,
|
|
13
|
+
setDraftPreviewHosts,
|
|
14
|
+
} from "./draftSource";
|
|
15
|
+
|
|
16
|
+
const ENV_ON = { DECO_ALLOWED_PREVIEW_HOSTS: "preview.example" };
|
|
17
|
+
|
|
18
|
+
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
|
19
|
+
return new Response(JSON.stringify(body), {
|
|
20
|
+
status: 200,
|
|
21
|
+
headers: { "content-type": "application/json" },
|
|
22
|
+
...init,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
clearDraftCache();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("parseDraftPointer", () => {
|
|
31
|
+
it("parses authority@version, lowercasing the authority", () => {
|
|
32
|
+
expect(parseDraftPointer("ABC.Preview-Studio.decocms.com@FF00")).toEqual({
|
|
33
|
+
host: "abc.preview-studio.decocms.com",
|
|
34
|
+
version: "FF00",
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("keeps an explicit port on the authority", () => {
|
|
39
|
+
expect(parseDraftPointer("abc.localhost:60534@v1")).toEqual({
|
|
40
|
+
host: "abc.localhost:60534",
|
|
41
|
+
version: "v1",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rejects more than one @", () => {
|
|
46
|
+
// A naive split("@") accepts this and silently uses the first two
|
|
47
|
+
// segments — the exact hole found while spiking the fetch path.
|
|
48
|
+
expect(parseDraftPointer("a.example@b@c")).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("rejects anything that could escape the authority", () => {
|
|
52
|
+
// No scheme, no path, no userinfo — the token carries an authority only,
|
|
53
|
+
// so `javascript:`/`file:`/full URLs fail structurally at parse time.
|
|
54
|
+
expect(parseDraftPointer("https://evil.example@v1")).toBeNull();
|
|
55
|
+
expect(parseDraftPointer("evil.example/x@v1")).toBeNull();
|
|
56
|
+
expect(parseDraftPointer("a.example:80:80@v1")).toBeNull();
|
|
57
|
+
expect(parseDraftPointer("a.example:abc@v1")).toBeNull();
|
|
58
|
+
expect(parseDraftPointer(".leading.dot@v1")).toBeNull();
|
|
59
|
+
expect(parseDraftPointer("bare-label@v1")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("validates the version charset — it becomes a cache key", () => {
|
|
63
|
+
expect(parseDraftPointer("a.example@")).toBeNull();
|
|
64
|
+
expect(parseDraftPointer(`a.example@${"x".repeat(65)}`)).toBeNull();
|
|
65
|
+
expect(parseDraftPointer("a.example@v 1")).toBeNull();
|
|
66
|
+
expect(parseDraftPointer(null)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("previewApiOriginForHost", () => {
|
|
71
|
+
it("admits authorities under the default deco domains", () => {
|
|
72
|
+
expect(previewApiOriginForHost("abc.preview-studio.decocms.com", {})).toBe(
|
|
73
|
+
"https://abc.preview-studio.decocms.com",
|
|
74
|
+
);
|
|
75
|
+
expect(previewApiOriginForHost("abc.local.studio.decocms.com", {})).toBe(
|
|
76
|
+
"https://abc.local.studio.decocms.com",
|
|
77
|
+
);
|
|
78
|
+
expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe("http://abc.localhost:60534");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("rejects hosts outside the domains — the token proposes, config disposes", () => {
|
|
82
|
+
expect(previewApiOriginForHost("evil.example", {})).toBeNull();
|
|
83
|
+
// Dot-prefixed suffixes guarantee a label boundary: a lookalike domain
|
|
84
|
+
// that merely ends with the same characters cannot pass.
|
|
85
|
+
expect(previewApiOriginForHost("evil-preview-studio.decocms.com", {})).toBeNull();
|
|
86
|
+
// The domain itself (no label in front) is not a draft host.
|
|
87
|
+
expect(previewApiOriginForHost("preview-studio.decocms.com", {})).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("allows an explicit port only under localhost-ish domains", () => {
|
|
91
|
+
// A public-domain token must not steer the fetch at odd ports.
|
|
92
|
+
expect(previewApiOriginForHost("abc.preview-studio.decocms.com:8500", {})).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("honours a configured override instead of the defaults", () => {
|
|
96
|
+
const env = { DECO_PREVIEW_API_DOMAINS: ".staging.example" };
|
|
97
|
+
expect(previewApiOriginForHost("abc.staging.example", env)).toBe("https://abc.staging.example");
|
|
98
|
+
expect(previewApiOriginForHost("abc.preview-studio.decocms.com", env)).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("gating", () => {
|
|
103
|
+
it("is on iff an allowed host is configured — API domains have defaults", () => {
|
|
104
|
+
expect(isDraftPreviewEnabled(ENV_ON)).toBe(true);
|
|
105
|
+
expect(isDraftPreviewEnabled({})).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("matches request hosts verbatim, port included, case-insensitively", () => {
|
|
109
|
+
const env = { DECO_ALLOWED_PREVIEW_HOSTS: "fila.vtex.app, localhost:3100" };
|
|
110
|
+
expect(isDraftHostAllowed("FILA.VTEX.APP", env)).toBe(true);
|
|
111
|
+
expect(isDraftHostAllowed("localhost:3100", env)).toBe(true);
|
|
112
|
+
expect(isDraftHostAllowed("fila.com.br", env)).toBe(false);
|
|
113
|
+
expect(isDraftHostAllowed("localhost", env)).toBe(false);
|
|
114
|
+
expect(isDraftHostAllowed(null, env)).toBe(false);
|
|
115
|
+
expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("resolveDraftDecofile", () => {
|
|
120
|
+
it("fetches exactly the token's validated origin", async () => {
|
|
121
|
+
const calls: string[] = [];
|
|
122
|
+
const blocks = await resolveDraftDecofile({
|
|
123
|
+
pointer: "abc.preview-studio.decocms.com@v1",
|
|
124
|
+
env: ENV_ON,
|
|
125
|
+
fetchImpl: (async (url: string) => {
|
|
126
|
+
calls.push(String(url));
|
|
127
|
+
return jsonResponse({ "pages-home": { title: "draft" } });
|
|
128
|
+
}) as unknown as typeof fetch,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(blocks).toEqual({ "pages-home": { title: "draft" } });
|
|
132
|
+
expect(calls).toEqual(["https://abc.preview-studio.decocms.com/_sandbox/decofile"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("is inert without a host allowlist — no fetch at all", async () => {
|
|
136
|
+
let called = false;
|
|
137
|
+
const blocks = await resolveDraftDecofile({
|
|
138
|
+
pointer: "abc.preview-studio.decocms.com@v1",
|
|
139
|
+
env: {},
|
|
140
|
+
fetchImpl: (async () => {
|
|
141
|
+
called = true;
|
|
142
|
+
return jsonResponse({});
|
|
143
|
+
}) as unknown as typeof fetch,
|
|
144
|
+
});
|
|
145
|
+
expect(blocks).toBeNull();
|
|
146
|
+
expect(called).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("refuses a parseable token whose origin no domain admits — no fetch", async () => {
|
|
150
|
+
let called = false;
|
|
151
|
+
const blocks = await resolveDraftDecofile({
|
|
152
|
+
pointer: "abc.evil.example@v1",
|
|
153
|
+
env: ENV_ON,
|
|
154
|
+
fetchImpl: (async () => {
|
|
155
|
+
called = true;
|
|
156
|
+
return jsonResponse({});
|
|
157
|
+
}) as unknown as typeof fetch,
|
|
158
|
+
});
|
|
159
|
+
expect(blocks).toBeNull();
|
|
160
|
+
expect(called).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("caches by version — one fetch per version, not per request", async () => {
|
|
164
|
+
let fetches = 0;
|
|
165
|
+
const fetchImpl = (async () => {
|
|
166
|
+
fetches++;
|
|
167
|
+
return jsonResponse({ n: fetches });
|
|
168
|
+
}) as unknown as typeof fetch;
|
|
169
|
+
const P = "abc.preview-studio.decocms.com";
|
|
170
|
+
|
|
171
|
+
const a = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
|
|
172
|
+
const b = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
|
|
173
|
+
expect(fetches).toBe(1);
|
|
174
|
+
expect(b).toBe(a);
|
|
175
|
+
|
|
176
|
+
await resolveDraftDecofile({ pointer: `${P}@v2`, env: ENV_ON, fetchImpl });
|
|
177
|
+
expect(fetches).toBe(2);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("bounds the cache so multi-MB decofiles can't accumulate", async () => {
|
|
181
|
+
let fetches = 0;
|
|
182
|
+
const fetchImpl = (async () => {
|
|
183
|
+
fetches++;
|
|
184
|
+
return jsonResponse({ n: fetches });
|
|
185
|
+
}) as unknown as typeof fetch;
|
|
186
|
+
const P = "abc.preview-studio.decocms.com";
|
|
187
|
+
|
|
188
|
+
for (const v of ["v1", "v2", "v3", "v4"]) {
|
|
189
|
+
await resolveDraftDecofile({ pointer: `${P}@${v}`, env: ENV_ON, fetchImpl });
|
|
190
|
+
}
|
|
191
|
+
expect(fetches).toBe(4);
|
|
192
|
+
await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
|
|
193
|
+
expect(fetches).toBe(5); // v1 evicted (cap 3) — re-fetch, never stale
|
|
194
|
+
await resolveDraftDecofile({ pointer: `${P}@v4`, env: ENV_ON, fetchImpl });
|
|
195
|
+
expect(fetches).toBe(5); // v4 resident
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("degrades to published on unreachable / non-2xx / unparseable", async () => {
|
|
199
|
+
const P = "abc.preview-studio.decocms.com";
|
|
200
|
+
for (const fetchImpl of [
|
|
201
|
+
async () => {
|
|
202
|
+
throw new Error("ECONNREFUSED");
|
|
203
|
+
},
|
|
204
|
+
async () => new Response("nope", { status: 404 }),
|
|
205
|
+
async () =>
|
|
206
|
+
new Response("<html>not json</html>", {
|
|
207
|
+
status: 200,
|
|
208
|
+
headers: { "content-type": "text/html" },
|
|
209
|
+
}),
|
|
210
|
+
]) {
|
|
211
|
+
clearDraftCache();
|
|
212
|
+
expect(
|
|
213
|
+
await resolveDraftDecofile({
|
|
214
|
+
pointer: `${P}@v1`,
|
|
215
|
+
env: ENV_ON,
|
|
216
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
217
|
+
}),
|
|
218
|
+
).toBeNull();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// DEFAULT_PREVIEW_API_DOMAINS is part of the public contract — pin it.
|
|
224
|
+
describe("DEFAULT_PREVIEW_API_DOMAINS", () => {
|
|
225
|
+
it("ships the deco-operated origins, dot-prefixed", () => {
|
|
226
|
+
expect(DEFAULT_PREVIEW_API_DOMAINS).toEqual([
|
|
227
|
+
".preview-studio.decocms.com",
|
|
228
|
+
".local.studio.decocms.com",
|
|
229
|
+
".localhost",
|
|
230
|
+
]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("site-block preview hosts", () => {
|
|
235
|
+
it("enables the feature from the site block alone — no env needed", () => {
|
|
236
|
+
setDraftPreviewHosts(["fila.vtex.app", "LOCALHOST:3100", 42, " "]);
|
|
237
|
+
try {
|
|
238
|
+
expect(isDraftPreviewEnabled({})).toBe(true);
|
|
239
|
+
expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(true);
|
|
240
|
+
// Sanitized: lowercased, non-strings and blanks dropped.
|
|
241
|
+
expect(isDraftHostAllowed("localhost:3100", {})).toBe(true);
|
|
242
|
+
expect(isDraftHostAllowed("evil.example", {})).toBe(false);
|
|
243
|
+
} finally {
|
|
244
|
+
setDraftPreviewHosts([]);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("env REPLACES the block hosts when set — the operational escape hatch", () => {
|
|
249
|
+
setDraftPreviewHosts(["fila.vtex.app"]);
|
|
250
|
+
try {
|
|
251
|
+
const env = { DECO_ALLOWED_PREVIEW_HOSTS: "other.example" };
|
|
252
|
+
expect(isDraftHostAllowed("other.example", env)).toBe(true);
|
|
253
|
+
// Not merged: env is a kill switch / override, so the block value must
|
|
254
|
+
// not survive alongside it.
|
|
255
|
+
expect(isDraftHostAllowed("fila.vtex.app", env)).toBe(false);
|
|
256
|
+
} finally {
|
|
257
|
+
setDraftPreviewHosts([]);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("draftPointerFromRequest", () => {
|
|
263
|
+
it("reads the pointer from the __deco_draft cookie (in-preview navigation)", () => {
|
|
264
|
+
const req = new Request("https://preview.example/deco/invoke/site/loaders/x.ts", {
|
|
265
|
+
headers: { cookie: "a=1; __deco_draft=abc.preview-studio.decocms.com@v1; b=2" },
|
|
266
|
+
});
|
|
267
|
+
expect(draftPointerFromRequest(req)).toBe("abc.preview-studio.decocms.com@v1");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("lets ?__draft= win over the cookie", () => {
|
|
271
|
+
const req = new Request("https://preview.example/p?__draft=h@v2", {
|
|
272
|
+
headers: { cookie: "__deco_draft=h@v1" },
|
|
273
|
+
});
|
|
274
|
+
expect(draftPointerFromRequest(req)).toBe("h@v2");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("returns null on ?__draft=off even with a cookie set", () => {
|
|
278
|
+
const req = new Request("https://preview.example/p?__draft=off", {
|
|
279
|
+
headers: { cookie: "__deco_draft=h@v1" },
|
|
280
|
+
});
|
|
281
|
+
expect(draftPointerFromRequest(req)).toBeNull();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("returns null with neither param nor cookie", () => {
|
|
285
|
+
expect(draftPointerFromRequest(new Request("https://preview.example/p"))).toBeNull();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("resolveDraftForRequest", () => {
|
|
290
|
+
const ENV = { DECO_ALLOWED_PREVIEW_HOSTS: "preview.example" };
|
|
291
|
+
function invokeReq(host = "preview.example"): Request {
|
|
292
|
+
return new Request("https://preview.example/deco/invoke/site/loaders/x.ts", {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: {
|
|
295
|
+
"x-forwarded-host": host,
|
|
296
|
+
cookie: "__deco_draft=abc.preview-studio.decocms.com@v1",
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
it("binds the draft when host is allowed and the pointer resolves", async () => {
|
|
302
|
+
const fetchImpl = (async () =>
|
|
303
|
+
jsonResponse({ "site/x": { value: "draft" } })) as unknown as typeof fetch;
|
|
304
|
+
expect(await resolveDraftForRequest(invokeReq(), { env: ENV, fetchImpl })).toEqual({
|
|
305
|
+
"site/x": { value: "draft" },
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("is inert with no allowlist — never touches the network", async () => {
|
|
310
|
+
let called = false;
|
|
311
|
+
const fetchImpl = (async () => {
|
|
312
|
+
called = true;
|
|
313
|
+
return jsonResponse({});
|
|
314
|
+
}) as unknown as typeof fetch;
|
|
315
|
+
expect(await resolveDraftForRequest(invokeReq(), { env: {}, fetchImpl })).toBeNull();
|
|
316
|
+
expect(called).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("returns null when the request host is not on the allowlist (prod stays published)", async () => {
|
|
320
|
+
let called = false;
|
|
321
|
+
const fetchImpl = (async () => {
|
|
322
|
+
called = true;
|
|
323
|
+
return jsonResponse({});
|
|
324
|
+
}) as unknown as typeof fetch;
|
|
325
|
+
expect(
|
|
326
|
+
await resolveDraftForRequest(invokeReq("prod.example"), { env: ENV, fetchImpl }),
|
|
327
|
+
).toBeNull();
|
|
328
|
+
expect(called).toBe(false);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("returns null when the request carries no draft pointer", async () => {
|
|
332
|
+
const req = new Request("https://preview.example/deco/invoke/site/loaders/x.ts", {
|
|
333
|
+
headers: { "x-forwarded-host": "preview.example" },
|
|
334
|
+
});
|
|
335
|
+
let called = false;
|
|
336
|
+
const fetchImpl = (async () => {
|
|
337
|
+
called = true;
|
|
338
|
+
return jsonResponse({});
|
|
339
|
+
}) as unknown as typeof fetch;
|
|
340
|
+
expect(await resolveDraftForRequest(req, { env: ENV, fetchImpl })).toBeNull();
|
|
341
|
+
expect(called).toBe(false);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft preview — pull-based decofile override.
|
|
3
|
+
*
|
|
4
|
+
* A Studio preview serves the working-tree draft at
|
|
5
|
+
* `GET <origin>/_sandbox/decofile`; a production site pulls it and renders its
|
|
6
|
+
* own real pages against it. This replaces pushing the decofile into a POST
|
|
7
|
+
* body, which only deco's own runtime honours — Next.js and most frameworks
|
|
8
|
+
* render on GET only.
|
|
9
|
+
*
|
|
10
|
+
* This module is the framework-agnostic half: token parsing, origin
|
|
11
|
+
* validation, fetching, and version caching. Binding a resolved draft to a
|
|
12
|
+
* request is framework-specific (see `@decocms/nextjs`'s draft wiring) and
|
|
13
|
+
* reaches this module through {@link setDraftOverrideGetter} — the same
|
|
14
|
+
* dependency-injection shape as `setFastDeployKVGetter`, so `blocks` keeps its
|
|
15
|
+
* zero-dependency direction.
|
|
16
|
+
*
|
|
17
|
+
* Inert unless `DECO_ALLOWED_PREVIEW_HOSTS` names the request's host: upgrading
|
|
18
|
+
* the package must never be enough to start fetching from the network and
|
|
19
|
+
* rendering unpublished content. Host-scoping (rather than a boolean) exists
|
|
20
|
+
* because one deployment commonly serves several domains — the preview domain
|
|
21
|
+
* may render drafts while the production domain, on the same build, must
|
|
22
|
+
* ignore a `?__draft=` entirely.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A parsed `?__draft=` token: `<host[:port]>@<version>`.
|
|
27
|
+
*
|
|
28
|
+
* The token carries the AUTHORITY of the draft content API, never a scheme or
|
|
29
|
+
* path — a full URL would be an SSRF vector, and the scheme is derived from
|
|
30
|
+
* the matched domain instead. Reserved evolution: a future signed token uses a
|
|
31
|
+
* distinguishable prefix (e.g. `s1.`), so this strict two-part parse rejects
|
|
32
|
+
* it cleanly rather than half-reading it.
|
|
33
|
+
*/
|
|
34
|
+
export interface DraftPointer {
|
|
35
|
+
/** Content-API authority, e.g. `abc.preview-studio.decocms.com` or `abc.localhost:60534`. */
|
|
36
|
+
host: string;
|
|
37
|
+
/** Opaque content version (the server's ETag). Immutable → safe cache key. */
|
|
38
|
+
version: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Lowercase DNS hostname, at least two labels (a bare label can't match any domain). */
|
|
42
|
+
const HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
43
|
+
const PORT_RE = /^[0-9]{1,5}$/;
|
|
44
|
+
const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse `<host[:port]>@<version>`. Null on anything unexpected — callers fall
|
|
48
|
+
* back to published content. Requires EXACTLY one `@`: a naive split accepts
|
|
49
|
+
* `a@b@c` and silently uses the first two segments.
|
|
50
|
+
*/
|
|
51
|
+
export function parseDraftPointer(raw: string | null | undefined): DraftPointer | null {
|
|
52
|
+
if (!raw) return null;
|
|
53
|
+
const parts = raw.split("@");
|
|
54
|
+
if (parts.length !== 2) return null;
|
|
55
|
+
const [authority, version] = parts;
|
|
56
|
+
if (!authority || !version || !VERSION_RE.test(version)) return null;
|
|
57
|
+
|
|
58
|
+
const [host, port, extra] = authority.toLowerCase().split(":");
|
|
59
|
+
if (extra !== undefined) return null;
|
|
60
|
+
if (!host || !HOST_RE.test(host)) return null;
|
|
61
|
+
if (port !== undefined && !PORT_RE.test(port)) return null;
|
|
62
|
+
|
|
63
|
+
return { host: port === undefined ? host : `${host}:${port}`, version };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Domains the draft content API may live under — deco-operated, so shipping
|
|
68
|
+
* them as defaults adds no SSRF surface. `DECO_PREVIEW_API_DOMAINS` overrides
|
|
69
|
+
* the whole list when set. Entries are dot-prefixed suffixes, which guarantees
|
|
70
|
+
* a label boundary on match (`evil-preview-studio.decocms.com` cannot pass).
|
|
71
|
+
*/
|
|
72
|
+
export const DEFAULT_PREVIEW_API_DOMAINS = [
|
|
73
|
+
".preview-studio.decocms.com",
|
|
74
|
+
".local.studio.decocms.com",
|
|
75
|
+
".localhost",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
function readApiDomains(env: Record<string, string | undefined>): string[] {
|
|
79
|
+
const configured = (env.DECO_PREVIEW_API_DOMAINS ?? "")
|
|
80
|
+
.split(",")
|
|
81
|
+
.map((s) => s.trim().toLowerCase())
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
return configured.length > 0 ? configured : DEFAULT_PREVIEW_API_DOMAINS;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate the token's authority against the configured domains and derive the
|
|
88
|
+
* fetch origin, or null if no domain admits it.
|
|
89
|
+
*
|
|
90
|
+
* The token proposes, configuration disposes: only the hostname-suffix match
|
|
91
|
+
* decides, so a caller can steer WHICH label under your domains, never which
|
|
92
|
+
* domains. Scheme is derived — http for localhost-ish domains, https
|
|
93
|
+
* otherwise — and an explicit port is allowed only there, so a public-domain
|
|
94
|
+
* token cannot aim at odd ports.
|
|
95
|
+
*/
|
|
96
|
+
export function previewApiOriginForHost(
|
|
97
|
+
authority: string,
|
|
98
|
+
env?: Record<string, string | undefined>,
|
|
99
|
+
): string | null {
|
|
100
|
+
const [host, port] = authority.toLowerCase().split(":");
|
|
101
|
+
if (!host) return null;
|
|
102
|
+
const domain = readApiDomains(envOrProcess(env)).find(
|
|
103
|
+
(d) => host.length > d.length && host.endsWith(d),
|
|
104
|
+
);
|
|
105
|
+
if (!domain) return null;
|
|
106
|
+
const local = domain.includes("localhost");
|
|
107
|
+
if (port !== undefined && !local) return null;
|
|
108
|
+
return `${local ? "http" : "https"}://${host}${port === undefined ? "" : `:${port}`}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Hosts declared by the site itself (the global `site` block's `previewHosts`),
|
|
113
|
+
* installed once at setup time by the framework binding.
|
|
114
|
+
*
|
|
115
|
+
* MUST be fed from the setup-time base blocks, never from `loadBlocks()` at
|
|
116
|
+
* request time: the request path merges the draft override, and an allowlist
|
|
117
|
+
* readable through the override could be rewritten by the very draft it gates.
|
|
118
|
+
*/
|
|
119
|
+
// globalThis-backed, like the block loader itself: bundlers can duplicate
|
|
120
|
+
// this module across graphs, and a plain module variable set in one instance
|
|
121
|
+
// is invisible to the others. (The MIDDLEWARE runtime is a separate world
|
|
122
|
+
// even so — which is why the page-side gate is the authoritative one and the
|
|
123
|
+
// middleware only hard-gates when the env override is present.)
|
|
124
|
+
const G = globalThis as { __decoDraftHosts?: string[] };
|
|
125
|
+
|
|
126
|
+
/** Install the site-declared preview hosts. Called by the framework binding at setup. */
|
|
127
|
+
export function setDraftPreviewHosts(hosts: readonly unknown[]): void {
|
|
128
|
+
G.__decoDraftHosts = hosts
|
|
129
|
+
.filter((h): h is string => typeof h === "string")
|
|
130
|
+
.map((h) => h.trim().toLowerCase())
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Hosts allowed to render drafts.
|
|
136
|
+
*
|
|
137
|
+
* The site block is the expected source — the opt-in lives in the repo,
|
|
138
|
+
* reviewed in a PR, versioned with branches. `DECO_ALLOWED_PREVIEW_HOSTS`
|
|
139
|
+
* REPLACES it when set: an operational escape hatch (kill a bad value without
|
|
140
|
+
* a deploy, add a machine-specific port) — not the primary configuration.
|
|
141
|
+
*/
|
|
142
|
+
function readAllowedHosts(env: Record<string, string | undefined>): string[] {
|
|
143
|
+
const fromEnv = (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "")
|
|
144
|
+
.split(",")
|
|
145
|
+
.map((s) => s.trim().toLowerCase())
|
|
146
|
+
.filter(Boolean);
|
|
147
|
+
return fromEnv.length > 0 ? fromEnv : (G.__decoDraftHosts ?? []);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether `host` (as seen on the request) may render drafts.
|
|
152
|
+
*
|
|
153
|
+
* Compared against `DECO_ALLOWED_PREVIEW_HOSTS` verbatim, port included —
|
|
154
|
+
* local dev is `localhost:3100`, not `localhost`. The header is spoofable by a
|
|
155
|
+
* direct-to-origin request, but the draft id is the actual capability;
|
|
156
|
+
* host-scoping bounds blast radius (production domains stay inert), it is not
|
|
157
|
+
* a secret.
|
|
158
|
+
*/
|
|
159
|
+
export function isDraftHostAllowed(
|
|
160
|
+
host: string | null | undefined,
|
|
161
|
+
env?: Record<string, string | undefined>,
|
|
162
|
+
): boolean {
|
|
163
|
+
if (!host) return false;
|
|
164
|
+
return readAllowedHosts(envOrProcess(env)).includes(host.trim().toLowerCase());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* True when any host is allowed to preview. A plain env read — callers use it
|
|
169
|
+
* to gate BEFORE touching dynamic APIs (`cookies()`/`headers()`), so an
|
|
170
|
+
* unconfigured site never loses static/ISR rendering. The per-request host
|
|
171
|
+
* match happens later, in `isDraftHostAllowed`.
|
|
172
|
+
*/
|
|
173
|
+
export function isDraftPreviewEnabled(env?: Record<string, string | undefined>): boolean {
|
|
174
|
+
return readAllowedHosts(envOrProcess(env)).length > 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function envOrProcess(
|
|
178
|
+
env?: Record<string, string | undefined>,
|
|
179
|
+
): Record<string, string | undefined> {
|
|
180
|
+
return (
|
|
181
|
+
env ??
|
|
182
|
+
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ??
|
|
183
|
+
{}
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Version cache. Bounded on purpose: a decofile is routinely multi-megabyte,
|
|
189
|
+
* so an unbounded map keyed by version would grow with every save until the
|
|
190
|
+
* process died. Content-addressed, so a hit is always correct.
|
|
191
|
+
*/
|
|
192
|
+
const MAX_CACHED_VERSIONS = 3;
|
|
193
|
+
const byVersion = new Map<string, Record<string, unknown>>();
|
|
194
|
+
|
|
195
|
+
function cacheDraft(version: string, blocks: Record<string, unknown>): void {
|
|
196
|
+
byVersion.delete(version);
|
|
197
|
+
byVersion.set(version, blocks);
|
|
198
|
+
while (byVersion.size > MAX_CACHED_VERSIONS) {
|
|
199
|
+
const oldest = byVersion.keys().next().value;
|
|
200
|
+
if (oldest === undefined) break;
|
|
201
|
+
byVersion.delete(oldest);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Test seam — drops every cached version. */
|
|
206
|
+
export function clearDraftCache(): void {
|
|
207
|
+
byVersion.clear();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface ResolveDraftOptions {
|
|
211
|
+
/** Raw `<host[:port]>@<version>` token from the request. */
|
|
212
|
+
pointer: string | null | undefined;
|
|
213
|
+
/** Defaults to `process.env`. */
|
|
214
|
+
env?: Record<string, string | undefined>;
|
|
215
|
+
/** Defaults to global `fetch`. Injected in tests. */
|
|
216
|
+
fetchImpl?: typeof fetch;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve a draft token to a decofile, or null to render published content.
|
|
221
|
+
*
|
|
222
|
+
* Null on every failure path — disabled, malformed token, disallowed origin,
|
|
223
|
+
* unreachable, non-2xx — because a draft that cannot be resolved must degrade
|
|
224
|
+
* to published rather than break the page.
|
|
225
|
+
*/
|
|
226
|
+
export async function resolveDraftDecofile(
|
|
227
|
+
options: ResolveDraftOptions,
|
|
228
|
+
): Promise<Record<string, unknown> | null> {
|
|
229
|
+
const env = envOrProcess(options.env);
|
|
230
|
+
if (readAllowedHosts(env).length === 0) return null;
|
|
231
|
+
|
|
232
|
+
const parsed = parseDraftPointer(options.pointer);
|
|
233
|
+
if (!parsed) return null;
|
|
234
|
+
|
|
235
|
+
const cached = byVersion.get(parsed.version);
|
|
236
|
+
if (cached) return cached;
|
|
237
|
+
|
|
238
|
+
const origin = previewApiOriginForHost(parsed.host, env);
|
|
239
|
+
if (!origin) return null;
|
|
240
|
+
|
|
241
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
242
|
+
let res: Response;
|
|
243
|
+
try {
|
|
244
|
+
res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" });
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
if (!res.ok) return null;
|
|
249
|
+
|
|
250
|
+
let blocks: Record<string, unknown>;
|
|
251
|
+
try {
|
|
252
|
+
blocks = (await res.json()) as Record<string, unknown>;
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
cacheDraft(parsed.version, blocks);
|
|
258
|
+
return blocks;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Request-level resolution (framework-agnostic; for secondary endpoints)
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Cookie the draft pointer travels in across navigation. The Next binding's
|
|
267
|
+
* middleware sets it on entry; a SECONDARY endpoint that must honour the same
|
|
268
|
+
* draft — `/deco/invoke`, which is a separate request from the page render —
|
|
269
|
+
* reads it back off the raw Request here.
|
|
270
|
+
*
|
|
271
|
+
* Duplicated (as a plain literal) in `@decocms/nextjs`'s client-safe
|
|
272
|
+
* `draftConstants`, because the client badge cannot import this server-only
|
|
273
|
+
* module. A parity test keeps the two in lock-step.
|
|
274
|
+
*/
|
|
275
|
+
export const DRAFT_COOKIE_NAME = "__deco_draft";
|
|
276
|
+
/** Query param that enters draft mode; `off` leaves it. */
|
|
277
|
+
export const DRAFT_QUERY_PARAM = "__draft";
|
|
278
|
+
|
|
279
|
+
/** Read one cookie value out of a raw `Cookie:` header. */
|
|
280
|
+
function readCookieValue(cookieHeader: string | null | undefined, name: string): string | null {
|
|
281
|
+
if (!cookieHeader) return null;
|
|
282
|
+
for (const part of cookieHeader.split(";")) {
|
|
283
|
+
const eq = part.indexOf("=");
|
|
284
|
+
if (eq === -1) continue;
|
|
285
|
+
if (part.slice(0, eq).trim() === name) {
|
|
286
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The draft pointer a raw Request is carrying: `?__draft=` wins, the cookie
|
|
294
|
+
* carries navigation, `off` exits. Same precedence as the Next binding's
|
|
295
|
+
* page-side `selectDraftPointer`, but reads straight off the Request so it
|
|
296
|
+
* works in a plain route handler (no `next/headers`, no React scope).
|
|
297
|
+
*/
|
|
298
|
+
export function draftPointerFromRequest(request: Request): string | null {
|
|
299
|
+
const url = new URL(request.url);
|
|
300
|
+
const param = url.searchParams.get(DRAFT_QUERY_PARAM);
|
|
301
|
+
if (param === "off") return null;
|
|
302
|
+
if (param) return param;
|
|
303
|
+
return readCookieValue(request.headers.get("cookie"), DRAFT_COOKIE_NAME);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export interface ResolveDraftForRequestOptions {
|
|
307
|
+
env?: Record<string, string | undefined>;
|
|
308
|
+
fetchImpl?: typeof fetch;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Resolve the draft decofile a raw Request is asking for, or null to fall back
|
|
313
|
+
* to published blocks.
|
|
314
|
+
*
|
|
315
|
+
* Applies the SAME gate as the page path — allowlist non-empty, request host
|
|
316
|
+
* allowed, pointer valid, origin allowed, fetch OK — so a secondary endpoint
|
|
317
|
+
* can bind the identical draft the page is rendering instead of silently
|
|
318
|
+
* serving published content. Wrap the endpoint's work in
|
|
319
|
+
* `withBlocksOverride(blocks, ...)` with the result; null means "don't wrap".
|
|
320
|
+
*
|
|
321
|
+
* This is what lets `/deco/invoke` (client-fetched lazy sections, self-fetched
|
|
322
|
+
* section loaders) honour a draft: the draft binding lives in per-request state
|
|
323
|
+
* that does not travel across an HTTP hop, so the endpoint must re-resolve it
|
|
324
|
+
* from the request it received.
|
|
325
|
+
*/
|
|
326
|
+
export async function resolveDraftForRequest(
|
|
327
|
+
request: Request,
|
|
328
|
+
options: ResolveDraftForRequestOptions = {},
|
|
329
|
+
): Promise<Record<string, unknown> | null> {
|
|
330
|
+
const env = envOrProcess(options.env);
|
|
331
|
+
if (readAllowedHosts(env).length === 0) return null;
|
|
332
|
+
const pointer = draftPointerFromRequest(request);
|
|
333
|
+
if (!pointer) return null;
|
|
334
|
+
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
|
335
|
+
if (!isDraftHostAllowed(host, env)) return null;
|
|
336
|
+
return resolveDraftDecofile({ pointer, env, fetchImpl: options.fetchImpl });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Request binding (dependency-injected by the framework binding)
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
type DraftOverrideGetter = () => Record<string, unknown> | null | undefined;
|
|
344
|
+
|
|
345
|
+
let getDraftOverride: DraftOverrideGetter = () => undefined;
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Inject the request-scoped draft getter.
|
|
349
|
+
*
|
|
350
|
+
* Binding a value to "the current request" is framework-specific and `blocks`
|
|
351
|
+
* must not know about any framework: `@decocms/nextjs` backs this with React
|
|
352
|
+
* `cache()` (App Router has no AsyncLocalStorage request scope of its own).
|
|
353
|
+
* Never called → returns undefined → `loadBlocks()` behaves exactly as before.
|
|
354
|
+
*/
|
|
355
|
+
export function setDraftOverrideGetter(getter: DraftOverrideGetter): void {
|
|
356
|
+
getDraftOverride = getter;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The current request's draft blocks, if a binding registered one. */
|
|
360
|
+
export function getRequestDraftOverride(): Record<string, unknown> | null | undefined {
|
|
361
|
+
return getDraftOverride();
|
|
362
|
+
}
|
package/src/cms/index.ts
CHANGED
|
@@ -12,6 +12,27 @@ export {
|
|
|
12
12
|
revisionKey,
|
|
13
13
|
snapshotKey,
|
|
14
14
|
} from "./blockSource";
|
|
15
|
+
export type {
|
|
16
|
+
DraftPointer,
|
|
17
|
+
ResolveDraftForRequestOptions,
|
|
18
|
+
ResolveDraftOptions,
|
|
19
|
+
} from "./draftSource";
|
|
20
|
+
export {
|
|
21
|
+
clearDraftCache,
|
|
22
|
+
DEFAULT_PREVIEW_API_DOMAINS,
|
|
23
|
+
DRAFT_COOKIE_NAME,
|
|
24
|
+
DRAFT_QUERY_PARAM,
|
|
25
|
+
draftPointerFromRequest,
|
|
26
|
+
getRequestDraftOverride,
|
|
27
|
+
isDraftHostAllowed,
|
|
28
|
+
isDraftPreviewEnabled,
|
|
29
|
+
parseDraftPointer,
|
|
30
|
+
previewApiOriginForHost,
|
|
31
|
+
resolveDraftDecofile,
|
|
32
|
+
resolveDraftForRequest,
|
|
33
|
+
setDraftOverrideGetter,
|
|
34
|
+
setDraftPreviewHosts,
|
|
35
|
+
} from "./draftSource";
|
|
15
36
|
export type { DecoPage, Resolvable } from "./loader";
|
|
16
37
|
export {
|
|
17
38
|
findPageByPath,
|
|
@@ -83,8 +104,6 @@ export {
|
|
|
83
104
|
unregisterCommerceLoader,
|
|
84
105
|
WELL_KNOWN_TYPES,
|
|
85
106
|
} from "./resolve";
|
|
86
|
-
export type { SectionLoaderContext } from "./sectionLoaderContext";
|
|
87
|
-
export { buildSectionLoaderContext } from "./sectionLoaderContext";
|
|
88
107
|
export type {
|
|
89
108
|
ActionConfig,
|
|
90
109
|
AppSchemas,
|
|
@@ -106,6 +125,8 @@ export {
|
|
|
106
125
|
registerMatcherSchema,
|
|
107
126
|
registerMatcherSchemas,
|
|
108
127
|
} from "./schema";
|
|
128
|
+
export type { SectionLoaderContext } from "./sectionLoaderContext";
|
|
129
|
+
export { buildSectionLoaderContext } from "./sectionLoaderContext";
|
|
109
130
|
export type { SectionLoaderFn } from "./sectionLoaders";
|
|
110
131
|
export {
|
|
111
132
|
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(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
|
+
});
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
it("matches an optional suffix group present and absent", () => {
|
|
@@ -112,9 +112,7 @@ 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(
|
|
116
|
-
/URLPattern.*Node\.js >= 24/s,
|
|
117
|
-
);
|
|
115
|
+
expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow(/URLPattern.*Node\.js >= 24/s);
|
|
118
116
|
} finally {
|
|
119
117
|
if (saved !== undefined) g.URLPattern = saved;
|
|
120
118
|
}
|
package/src/cms/loader.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as asyncHooks from "node:async_hooks";
|
|
2
2
|
import { djb2Hex } from "../sdk/djb2";
|
|
3
|
+
import { getRequestDraftOverride } from "./draftSource";
|
|
3
4
|
|
|
4
5
|
export type Resolvable = {
|
|
5
6
|
__resolveType?: string;
|
|
@@ -86,7 +87,8 @@ export function setBlocks(blocks: Record<string, unknown>) {
|
|
|
86
87
|
|
|
87
88
|
/**
|
|
88
89
|
* Load the current blocks. If running inside a `withBlocksOverride` scope
|
|
89
|
-
* (admin preview)
|
|
90
|
+
* (admin preview) or a request carrying a draft-preview override, that
|
|
91
|
+
* override is merged on top of the base blocks.
|
|
90
92
|
*/
|
|
91
93
|
export function loadBlocks(): Record<string, unknown> {
|
|
92
94
|
// Re-sync from globalThis in case setBlocks was called in another module instance
|
|
@@ -95,7 +97,10 @@ export function loadBlocks(): Record<string, unknown> {
|
|
|
95
97
|
revision = G.__deco.revision ?? null;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
|
|
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();
|
|
99
104
|
if (override) {
|
|
100
105
|
const merged = { ...blockData };
|
|
101
106
|
for (const [key, value] of Object.entries(override)) {
|
|
@@ -203,12 +208,14 @@ export function getAllPages(): Array<{ key: string; page: DecoPage }> {
|
|
|
203
208
|
// same regardless of whether @types/node has its own `URLPattern` global or
|
|
204
209
|
// not — there's nothing for a local declaration to collide with.
|
|
205
210
|
type MatchPatternResult = {
|
|
206
|
-
|
|
211
|
+
pathname: { groups: Record<string, string | undefined> };
|
|
207
212
|
};
|
|
208
213
|
declare const URLPattern: {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
214
|
+
new (init: {
|
|
215
|
+
pathname: string;
|
|
216
|
+
}): {
|
|
217
|
+
exec(input: { pathname: string }): MatchPatternResult | null;
|
|
218
|
+
};
|
|
212
219
|
};
|
|
213
220
|
|
|
214
221
|
/**
|
|
@@ -231,10 +238,7 @@ declare const URLPattern: {
|
|
|
231
238
|
* `URLPattern` is native in browsers, workerd, Deno, and Node >= 24 (this
|
|
232
239
|
* package's `engines` floor). Node 22 and older lack it.
|
|
233
240
|
*/
|
|
234
|
-
export function matchPath(
|
|
235
|
-
pattern: string,
|
|
236
|
-
urlPath: string,
|
|
237
|
-
): Record<string, string> | null {
|
|
241
|
+
export function matchPath(pattern: string, urlPath: string): Record<string, string> | null {
|
|
238
242
|
if (typeof URLPattern === "undefined") {
|
|
239
243
|
throw new Error(
|
|
240
244
|
"@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 { describe, expect, it
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
registerSectionsSync,
|
|
3
|
+
getResolvedComponent,
|
|
5
4
|
getSection,
|
|
6
5
|
getSectionOptions,
|
|
7
6
|
getSyncComponent,
|
|
8
|
-
|
|
7
|
+
registerSection,
|
|
8
|
+
registerSectionsSync,
|
|
9
9
|
} from "./registry";
|
|
10
10
|
|
|
11
11
|
// Reset globalThis.__deco between tests to avoid cross-test pollution
|
|
@@ -34,44 +34,36 @@ 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
|
-
|
|
40
|
-
{ loadingFallback: fallback },
|
|
41
|
-
);
|
|
37
|
+
registerSection("site/sections/Shelf.tsx", async () => ({ default: () => null }), {
|
|
38
|
+
loadingFallback: fallback,
|
|
39
|
+
});
|
|
42
40
|
|
|
43
41
|
const opts = getSectionOptions("site/sections/Shelf.tsx");
|
|
44
42
|
expect(opts?.loadingFallback).toBe(fallback);
|
|
45
43
|
});
|
|
46
44
|
|
|
47
45
|
it("stores clientOnly flag in section options", () => {
|
|
48
|
-
registerSection(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{ clientOnly: true },
|
|
52
|
-
);
|
|
46
|
+
registerSection("site/sections/Analytics.tsx", async () => ({ default: () => null }), {
|
|
47
|
+
clientOnly: true,
|
|
48
|
+
});
|
|
53
49
|
|
|
54
50
|
const opts = getSectionOptions("site/sections/Analytics.tsx");
|
|
55
51
|
expect(opts?.clientOnly).toBe(true);
|
|
56
52
|
});
|
|
57
53
|
|
|
58
54
|
it("clientOnly defaults to undefined when not set", () => {
|
|
59
|
-
registerSection(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
{ loadingFallback: () => null },
|
|
63
|
-
);
|
|
55
|
+
registerSection("site/sections/Normal.tsx", async () => ({ default: () => null }), {
|
|
56
|
+
loadingFallback: () => null,
|
|
57
|
+
});
|
|
64
58
|
|
|
65
59
|
const opts = getSectionOptions("site/sections/Normal.tsx");
|
|
66
60
|
expect(opts?.clientOnly).toBeUndefined();
|
|
67
61
|
});
|
|
68
62
|
|
|
69
63
|
it("clientOnly false is preserved", () => {
|
|
70
|
-
registerSection(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
{ clientOnly: false },
|
|
74
|
-
);
|
|
64
|
+
registerSection("site/sections/Explicit.tsx", async () => ({ default: () => null }), {
|
|
65
|
+
clientOnly: false,
|
|
66
|
+
});
|
|
75
67
|
|
|
76
68
|
const opts = getSectionOptions("site/sections/Explicit.tsx");
|
|
77
69
|
expect(opts?.clientOnly).toBe(false);
|
package/src/cms/registry.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { ComponentType } from "react";
|
|
2
2
|
|
|
3
|
-
export type OnBeforeResolveProps = (
|
|
4
|
-
props: Record<string, unknown>,
|
|
5
|
-
) => Record<string, unknown>;
|
|
3
|
+
export type OnBeforeResolveProps = (props: Record<string, unknown>) => Record<string, unknown>;
|
|
6
4
|
|
|
7
5
|
export type SectionModule = {
|
|
8
6
|
default: ComponentType<any>;
|
|
@@ -35,8 +33,7 @@ if (!G.__deco.sectionRegistry) G.__deco.sectionRegistry = {};
|
|
|
35
33
|
if (!G.__deco.sectionOptions) G.__deco.sectionOptions = {};
|
|
36
34
|
if (!G.__deco.resolvedComponents) G.__deco.resolvedComponents = {};
|
|
37
35
|
if (!G.__deco.syncComponents) G.__deco.syncComponents = {};
|
|
38
|
-
if (!G.__deco.onBeforeResolvePropsRegistry)
|
|
39
|
-
G.__deco.onBeforeResolvePropsRegistry = {};
|
|
36
|
+
if (!G.__deco.onBeforeResolvePropsRegistry) G.__deco.onBeforeResolvePropsRegistry = {};
|
|
40
37
|
|
|
41
38
|
const registry: Record<string, RegistryEntry> = G.__deco.sectionRegistry;
|
|
42
39
|
const sectionOptions: Record<string, SectionOptions> = G.__deco.sectionOptions;
|
|
@@ -194,9 +191,7 @@ export function registerSectionsSync(sections: Record<string, SyncSectionEntry>)
|
|
|
194
191
|
];
|
|
195
192
|
const component =
|
|
196
193
|
typeof raw === "function" ||
|
|
197
|
-
(raw != null &&
|
|
198
|
-
typeof raw === "object" &&
|
|
199
|
-
REACT_WRAPPERS.includes((raw as any).$$typeof))
|
|
194
|
+
(raw != null && typeof raw === "object" && REACT_WRAPPERS.includes((raw as any).$$typeof))
|
|
200
195
|
? raw
|
|
201
196
|
: undefined;
|
|
202
197
|
if (!component) {
|
|
@@ -229,17 +224,12 @@ export function getSyncComponent(key: string): ComponentType<any> | undefined {
|
|
|
229
224
|
* BEFORE the resolution engine resolves them. Use to extract metadata from
|
|
230
225
|
* resolvable structures that would be lost after resolution.
|
|
231
226
|
*/
|
|
232
|
-
export function registerOnBeforeResolveProps(
|
|
233
|
-
sectionKey: string,
|
|
234
|
-
fn: OnBeforeResolveProps,
|
|
235
|
-
): void {
|
|
227
|
+
export function registerOnBeforeResolveProps(sectionKey: string, fn: OnBeforeResolveProps): void {
|
|
236
228
|
onBeforeResolvePropsRegistry[sectionKey] = fn;
|
|
237
229
|
}
|
|
238
230
|
|
|
239
231
|
/** Get the registered onBeforeResolveProps for a section, if any. */
|
|
240
|
-
export function getOnBeforeResolveProps(
|
|
241
|
-
sectionKey: string,
|
|
242
|
-
): OnBeforeResolveProps | undefined {
|
|
232
|
+
export function getOnBeforeResolveProps(sectionKey: string): OnBeforeResolveProps | undefined {
|
|
243
233
|
return onBeforeResolvePropsRegistry[sectionKey];
|
|
244
234
|
}
|
|
245
235
|
|
package/src/cms/resolve.test.ts
CHANGED
|
@@ -20,6 +20,10 @@ 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";
|
|
23
27
|
import {
|
|
24
28
|
clearCommerceLoaders,
|
|
25
29
|
DEFAULT_FOLD_THRESHOLD,
|
|
@@ -29,8 +33,8 @@ import {
|
|
|
29
33
|
registerCommerceLoader,
|
|
30
34
|
registerEagerSections,
|
|
31
35
|
registerNeverDeferSections,
|
|
32
|
-
resolveDeferredSectionFull,
|
|
33
36
|
resolveDecoPage,
|
|
37
|
+
resolveDeferredSectionFull,
|
|
34
38
|
resolvePageSeoBlock,
|
|
35
39
|
resolveSectionsList,
|
|
36
40
|
resolveValue,
|
|
@@ -39,10 +43,6 @@ import {
|
|
|
39
43
|
WELL_KNOWN_TYPES,
|
|
40
44
|
} from "./resolve";
|
|
41
45
|
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,14 +217,10 @@ describe("commerce loader auto-injects URL search params as props", () => {
|
|
|
217
217
|
return null;
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
-
await resolveValue(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
url: "https://store.com/produto/sabonete/p?skuId=12345&size=M",
|
|
225
|
-
path: "/produto/sabonete/p",
|
|
226
|
-
},
|
|
227
|
-
);
|
|
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
|
+
});
|
|
228
224
|
|
|
229
225
|
expect(calls).toHaveLength(1);
|
|
230
226
|
expect(calls[0]).toMatchObject({
|
|
@@ -243,11 +239,10 @@ describe("commerce loader auto-injects URL search params as props", () => {
|
|
|
243
239
|
return null;
|
|
244
240
|
});
|
|
245
241
|
|
|
246
|
-
await resolveValue(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
);
|
|
242
|
+
await resolveValue({ __resolveType: KEY, skuId: "cms-locked-sku" }, undefined, {
|
|
243
|
+
url: "https://store.com/p?skuId=url-value",
|
|
244
|
+
path: "/p",
|
|
245
|
+
});
|
|
251
246
|
|
|
252
247
|
expect(calls[0]?.skuId).toBe("cms-locked-sku");
|
|
253
248
|
});
|
|
@@ -358,7 +353,10 @@ describe("commerce loader resolves legacy .ts-suffixed resolveType", () => {
|
|
|
358
353
|
await resolveValue(
|
|
359
354
|
{ __resolveType: "shopify/loaders/ProductDetailsPage.ts", slug: "oversize-t-shirt-123" },
|
|
360
355
|
undefined,
|
|
361
|
-
{
|
|
356
|
+
{
|
|
357
|
+
url: "https://store.com/products/oversize-t-shirt-123",
|
|
358
|
+
path: "/products/oversize-t-shirt-123",
|
|
359
|
+
},
|
|
362
360
|
);
|
|
363
361
|
|
|
364
362
|
expect(calls).toHaveLength(1);
|
|
@@ -509,9 +507,9 @@ describe("isEagerRequest — programmatic fetch detection", () => {
|
|
|
509
507
|
});
|
|
510
508
|
|
|
511
509
|
it("falls back to matcherCtx.headers when no Request is present", () => {
|
|
512
|
-
expect(
|
|
513
|
-
|
|
514
|
-
)
|
|
510
|
+
expect(isEagerRequest({ userAgent: HUMAN_UA, headers: { "sec-fetch-dest": "empty" } })).toBe(
|
|
511
|
+
true,
|
|
512
|
+
);
|
|
515
513
|
});
|
|
516
514
|
|
|
517
515
|
it("a request with no Sec-Fetch headers stays deferred (no UA bot, no override)", () => {
|
package/src/cms/resolve.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type ActionConfig,
|
|
3
|
-
inferLoaderTags,
|
|
4
|
-
type LoaderConfig,
|
|
5
|
-
registerActionSchemas,
|
|
6
|
-
registerLoaderSchemas,
|
|
7
|
-
} from "./schema";
|
|
8
1
|
import { getMatchersOverride, getRuleOverrideId, hasMatchersOverride } from "../matchers/override";
|
|
9
2
|
import { getMeter, MetricNames, withTracing } from "../middleware/observability";
|
|
10
3
|
import { djb2Hex } from "../sdk/djb2";
|
|
@@ -13,6 +6,13 @@ import { withInflightTimeout } from "../sdk/inflightTimeout";
|
|
|
13
6
|
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
14
7
|
import { findPageByPath, loadBlocks } from "./loader";
|
|
15
8
|
import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry";
|
|
9
|
+
import {
|
|
10
|
+
type ActionConfig,
|
|
11
|
+
inferLoaderTags,
|
|
12
|
+
type LoaderConfig,
|
|
13
|
+
registerActionSchemas,
|
|
14
|
+
registerLoaderSchemas,
|
|
15
|
+
} from "./schema";
|
|
16
16
|
import { isLayoutSection, markSectionDegraded, runSingleSectionLoader } from "./sectionLoaders";
|
|
17
17
|
|
|
18
18
|
// globalThis-backed: share state across Vite server function split modules
|
|
@@ -235,7 +235,7 @@ function isEagerSection(key: string): boolean {
|
|
|
235
235
|
* during hydration (search filters, configurators, etc.).
|
|
236
236
|
*/
|
|
237
237
|
export function registerNeverDeferSections(keys: string[]): void {
|
|
238
|
-
const set: Set<string> = G.__deco.neverDeferSectionKeys ??= new Set();
|
|
238
|
+
const set: Set<string> = (G.__deco.neverDeferSectionKeys ??= new Set());
|
|
239
239
|
for (const k of keys) set.add(k);
|
|
240
240
|
}
|
|
241
241
|
|
|
@@ -350,8 +350,7 @@ function hasForceEagerParam(ctx?: MatcherContext): boolean {
|
|
|
350
350
|
*/
|
|
351
351
|
function isProgrammaticFetch(ctx?: MatcherContext): boolean {
|
|
352
352
|
if (ctx?.isClientNavigation) return false;
|
|
353
|
-
const dest = ctx?.request?.headers.get("sec-fetch-dest") ??
|
|
354
|
-
ctx?.headers?.["sec-fetch-dest"];
|
|
353
|
+
const dest = ctx?.request?.headers.get("sec-fetch-dest") ?? ctx?.headers?.["sec-fetch-dest"];
|
|
355
354
|
return dest === "empty";
|
|
356
355
|
}
|
|
357
356
|
|
|
@@ -362,8 +361,7 @@ function isProgrammaticFetch(ctx?: MatcherContext): boolean {
|
|
|
362
361
|
* to gate both section deferral and page-SEO commerce resolution.
|
|
363
362
|
*/
|
|
364
363
|
export function isEagerRequest(ctx?: MatcherContext): boolean {
|
|
365
|
-
return isBot(ctx?.userAgent) || hasForceEagerParam(ctx) ||
|
|
366
|
-
isProgrammaticFetch(ctx);
|
|
364
|
+
return isBot(ctx?.userAgent) || hasForceEagerParam(ctx) || isProgrammaticFetch(ctx);
|
|
367
365
|
}
|
|
368
366
|
|
|
369
367
|
/**
|
|
@@ -743,7 +741,9 @@ function evaluateVariantRule(
|
|
|
743
741
|
const already = ctx.flags?.find((f) => f.name === meta.name && f.pct === meta.pct);
|
|
744
742
|
if (already) return already.value;
|
|
745
743
|
|
|
746
|
-
const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find(
|
|
744
|
+
const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find(
|
|
745
|
+
(f) => f.name === meta.name,
|
|
746
|
+
);
|
|
747
747
|
// pct === -1 marks a classic-deco segment without a fingerprint — honor it
|
|
748
748
|
// (stay sticky) instead of re-rolling. A stale fingerprint re-rolls.
|
|
749
749
|
const useStored = stored && (stored.pct === -1 || stored.pct === meta.pct);
|
|
@@ -1297,9 +1297,7 @@ function sectionIgnoresStructuredData(props: Record<string, unknown>): boolean {
|
|
|
1297
1297
|
* serialized into the HTML. Lightweight literal props (title, description,
|
|
1298
1298
|
* canonical, …) are preserved.
|
|
1299
1299
|
*/
|
|
1300
|
-
function stripCommerceLoaderProps(
|
|
1301
|
-
rawProps: Record<string, unknown>,
|
|
1302
|
-
): Record<string, unknown> {
|
|
1300
|
+
function stripCommerceLoaderProps(rawProps: Record<string, unknown>): Record<string, unknown> {
|
|
1303
1301
|
const out: Record<string, unknown> = {};
|
|
1304
1302
|
for (const [k, v] of Object.entries(rawProps)) {
|
|
1305
1303
|
if (resolvesToCommerceLoader(v)) continue;
|
package/src/cms/schema.ts
CHANGED
|
@@ -1100,10 +1100,7 @@ export interface ComposeMetaOptions {
|
|
|
1100
1100
|
framework?: string;
|
|
1101
1101
|
}
|
|
1102
1102
|
|
|
1103
|
-
export function composeMeta(
|
|
1104
|
-
siteMeta: MetaResponse,
|
|
1105
|
-
options?: ComposeMetaOptions,
|
|
1106
|
-
): MetaResponse {
|
|
1103
|
+
export function composeMeta(siteMeta: MetaResponse, options?: ComposeMetaOptions): MetaResponse {
|
|
1107
1104
|
// Idempotency guard. composeMeta is NOT structurally idempotent — it appends
|
|
1108
1105
|
// the framework section refs to `root.sections.anyOf` (and `__SECTION_REF__`),
|
|
1109
1106
|
// so composing an already-composed meta a second time duplicates those refs.
|
|
@@ -17,8 +17,7 @@ import {
|
|
|
17
17
|
// request must opt in by setting this flag — and `compose` must propagate
|
|
18
18
|
// it whenever any input has it.
|
|
19
19
|
const isRequestDependent = (fn: unknown): boolean =>
|
|
20
|
-
typeof fn === "function" &&
|
|
21
|
-
(fn as { __requestDependent?: boolean }).__requestDependent === true;
|
|
20
|
+
typeof fn === "function" && (fn as { __requestDependent?: boolean }).__requestDependent === true;
|
|
22
21
|
|
|
23
22
|
const makeReq = (url = "https://store.example/foo?q=hello") =>
|
|
24
23
|
new Request(url, { headers: { "user-agent": "vitest" } });
|
|
@@ -87,12 +86,10 @@ describe("withSectionLoader", () => {
|
|
|
87
86
|
|
|
88
87
|
it("composes alongside mixins: mixins run first, then the section loader sees the enriched props", async () => {
|
|
89
88
|
const seen: Array<Record<string, unknown>> = [];
|
|
90
|
-
const sectionLoader = vi.fn(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
},
|
|
95
|
-
);
|
|
89
|
+
const sectionLoader = vi.fn(async (props: Record<string, unknown>) => {
|
|
90
|
+
seen.push({ ...props });
|
|
91
|
+
return { ...props, sectionLoaderRan: true };
|
|
92
|
+
});
|
|
96
93
|
|
|
97
94
|
const composed = compose(
|
|
98
95
|
withSearchParam(),
|
|
@@ -154,7 +151,14 @@ describe("request-dependent tagging (#206)", () => {
|
|
|
154
151
|
});
|
|
155
152
|
|
|
156
153
|
it("compose does NOT set the flag when no input is request-dependent", () => {
|
|
157
|
-
expect(
|
|
154
|
+
expect(
|
|
155
|
+
isRequestDependent(
|
|
156
|
+
compose(
|
|
157
|
+
async (p) => p,
|
|
158
|
+
async (p) => p,
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
).toBe(false);
|
|
158
162
|
});
|
|
159
163
|
|
|
160
164
|
it("empty compose() is not request-dependent", () => {
|
package/src/cms/sectionMixins.ts
CHANGED
|
@@ -135,9 +135,7 @@ export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn {
|
|
|
135
135
|
* });
|
|
136
136
|
* ```
|
|
137
137
|
*/
|
|
138
|
-
export function withSectionLoader(
|
|
139
|
-
modImport: () => Promise<unknown>,
|
|
140
|
-
): SectionLoaderFn {
|
|
138
|
+
export function withSectionLoader(modImport: () => Promise<unknown>): SectionLoaderFn {
|
|
141
139
|
return async (props, req, ctx) => {
|
|
142
140
|
const mod = (await modImport()) as { loader?: unknown } | undefined;
|
|
143
141
|
const loader = mod?.loader;
|