@decocms/blocks 7.35.0 → 7.36.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/draftSource.test.ts +95 -64
- package/src/cms/draftSource.ts +97 -43
- package/src/cms/index.ts +10 -2
- package/src/cms/loader.test.ts +107 -13
- package/src/cms/loader.ts +92 -14
package/package.json
CHANGED
|
@@ -28,79 +28,94 @@ beforeEach(() => {
|
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
describe("parseDraftPointer", () => {
|
|
31
|
-
it("parses authority
|
|
32
|
-
expect(
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
it("parses <authority><path>@<version>, lowercasing the authority", () => {
|
|
32
|
+
expect(
|
|
33
|
+
parseDraftPointer(
|
|
34
|
+
"Studio.decocms.com/api/fila/decofile/vm-1/main?token=Tok.abc@8c1d44e",
|
|
35
|
+
),
|
|
36
|
+
).toEqual({
|
|
37
|
+
host: "studio.decocms.com",
|
|
38
|
+
path: "/api/fila/decofile/vm-1/main?token=Tok.abc",
|
|
39
|
+
version: "8c1d44e",
|
|
35
40
|
});
|
|
36
41
|
});
|
|
37
42
|
|
|
38
|
-
it("keeps an explicit port on the authority", () => {
|
|
39
|
-
expect(parseDraftPointer("
|
|
40
|
-
host: "
|
|
43
|
+
it("keeps an explicit port on the authority, incl. bare localhost", () => {
|
|
44
|
+
expect(parseDraftPointer("localhost:4000/api/o/decofile/m/b@v1")).toEqual({
|
|
45
|
+
host: "localhost:4000",
|
|
46
|
+
path: "/api/o/decofile/m/b",
|
|
41
47
|
version: "v1",
|
|
42
48
|
});
|
|
43
49
|
});
|
|
44
50
|
|
|
45
|
-
it("
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
expect(parseDraftPointer("a.example@
|
|
51
|
+
it("splits on the LAST @ — an earlier @ fails validation, never half-reads", () => {
|
|
52
|
+
// The path charset excludes `@`, so a smuggled one lands in `path` and
|
|
53
|
+
// fails PATH_RE rather than shifting the version boundary.
|
|
54
|
+
expect(parseDraftPointer("a.example/x@y/z@v1")).toBeNull();
|
|
55
|
+
expect(parseDraftPointer("a@b@c")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("requires a rooted path — authority-only tokens are gone", () => {
|
|
59
|
+
// Non-backcompat: the daemon-era `<authority>@<version>` form is invalid.
|
|
60
|
+
expect(parseDraftPointer("abc.preview-studio.decocms.com@v1")).toBeNull();
|
|
61
|
+
expect(parseDraftPointer("a.example@v1")).toBeNull();
|
|
49
62
|
});
|
|
50
63
|
|
|
51
|
-
it("rejects anything that could escape the authority", () => {
|
|
52
|
-
// No scheme, no
|
|
53
|
-
//
|
|
54
|
-
expect(parseDraftPointer("https://evil.example@v1")).toBeNull();
|
|
55
|
-
expect(parseDraftPointer("
|
|
56
|
-
expect(parseDraftPointer("a.example:
|
|
57
|
-
expect(parseDraftPointer("
|
|
58
|
-
expect(parseDraftPointer(".
|
|
59
|
-
expect(parseDraftPointer("
|
|
64
|
+
it("rejects anything that could escape the authority or path", () => {
|
|
65
|
+
// No scheme, no userinfo, no fragment/space in the path — a full URL
|
|
66
|
+
// fails structurally at parse time.
|
|
67
|
+
expect(parseDraftPointer("https://evil.example/x@v1")).toBeNull();
|
|
68
|
+
expect(parseDraftPointer("a.example:80:80/x@v1")).toBeNull();
|
|
69
|
+
expect(parseDraftPointer("a.example:abc/x@v1")).toBeNull();
|
|
70
|
+
expect(parseDraftPointer(".leading.dot/x@v1")).toBeNull();
|
|
71
|
+
expect(parseDraftPointer("a.example/x#frag@v1")).toBeNull();
|
|
72
|
+
expect(parseDraftPointer("a.example/x y@v1")).toBeNull();
|
|
60
73
|
});
|
|
61
74
|
|
|
62
75
|
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();
|
|
76
|
+
expect(parseDraftPointer("a.example/x@")).toBeNull();
|
|
77
|
+
expect(parseDraftPointer(`a.example/x@${"x".repeat(65)}`)).toBeNull();
|
|
78
|
+
expect(parseDraftPointer("a.example/x@v 1")).toBeNull();
|
|
66
79
|
expect(parseDraftPointer(null)).toBeNull();
|
|
67
80
|
});
|
|
68
81
|
});
|
|
69
82
|
|
|
70
83
|
describe("previewApiOriginForHost", () => {
|
|
71
84
|
it("admits authorities under the default deco domains", () => {
|
|
85
|
+
// Hosted Studio (the decofile API origin) via the .decocms.com suffix.
|
|
86
|
+
expect(previewApiOriginForHost("studio.decocms.com", {})).toBe(
|
|
87
|
+
"https://studio.decocms.com",
|
|
88
|
+
);
|
|
89
|
+
// Preview daemons keep working under the same suffix.
|
|
72
90
|
expect(previewApiOriginForHost("abc.preview-studio.decocms.com", {})).toBe(
|
|
73
91
|
"https://abc.preview-studio.decocms.com",
|
|
74
92
|
);
|
|
75
|
-
|
|
76
|
-
|
|
93
|
+
// Local dev: exact-host entries, http, explicit port allowed.
|
|
94
|
+
expect(previewApiOriginForHost("localhost:4000", {})).toBe(
|
|
95
|
+
"http://localhost:4000",
|
|
96
|
+
);
|
|
97
|
+
// The native app's dev origin serves TLS (locally-trusted cert): https,
|
|
98
|
+
// but the explicit port is still allowed.
|
|
99
|
+
expect(previewApiOriginForHost("local.studio.decocms.com:4420", {})).toBe(
|
|
100
|
+
"https://local.studio.decocms.com:4420",
|
|
77
101
|
);
|
|
78
102
|
expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe(
|
|
79
103
|
"http://abc.localhost:60534",
|
|
80
104
|
);
|
|
81
|
-
// -stg is its own suffix, not a substring match of .preview-studio.decocms.com —
|
|
82
|
-
// both must be listed explicitly (regressed once: staging draft pointers
|
|
83
|
-
// silently fell back to published content with no visible error).
|
|
84
|
-
expect(
|
|
85
|
-
previewApiOriginForHost("abc.preview-studio-stg.decocms.com", {}),
|
|
86
|
-
).toBe("https://abc.preview-studio-stg.decocms.com");
|
|
87
105
|
});
|
|
88
106
|
|
|
89
107
|
it("rejects hosts outside the domains — the token proposes, config disposes", () => {
|
|
90
108
|
expect(previewApiOriginForHost("evil.example", {})).toBeNull();
|
|
91
109
|
// Dot-prefixed suffixes guarantee a label boundary: a lookalike domain
|
|
92
110
|
// that merely ends with the same characters cannot pass.
|
|
93
|
-
expect(
|
|
94
|
-
|
|
95
|
-
).toBeNull();
|
|
96
|
-
// The domain itself (no label in front) is not a draft host.
|
|
97
|
-
expect(
|
|
98
|
-
previewApiOriginForHost("preview-studio.decocms.com", {}),
|
|
99
|
-
).toBeNull();
|
|
111
|
+
expect(previewApiOriginForHost("evil-decocms.com", {})).toBeNull();
|
|
112
|
+
// The suffix domain itself (no label in front) is not admitted.
|
|
113
|
+
expect(previewApiOriginForHost("decocms.com", {})).toBeNull();
|
|
100
114
|
});
|
|
101
115
|
|
|
102
|
-
it("allows an explicit port only
|
|
116
|
+
it("allows an explicit port only for local entries", () => {
|
|
103
117
|
// A public-domain token must not steer the fetch at odd ports.
|
|
118
|
+
expect(previewApiOriginForHost("studio.decocms.com:8500", {})).toBeNull();
|
|
104
119
|
expect(
|
|
105
120
|
previewApiOriginForHost("abc.preview-studio.decocms.com:8500", {}),
|
|
106
121
|
).toBeNull();
|
|
@@ -111,9 +126,7 @@ describe("previewApiOriginForHost", () => {
|
|
|
111
126
|
expect(previewApiOriginForHost("abc.staging.example", env)).toBe(
|
|
112
127
|
"https://abc.staging.example",
|
|
113
128
|
);
|
|
114
|
-
expect(
|
|
115
|
-
previewApiOriginForHost("abc.preview-studio.decocms.com", env),
|
|
116
|
-
).toBeNull();
|
|
129
|
+
expect(previewApiOriginForHost("studio.decocms.com", env)).toBeNull();
|
|
117
130
|
});
|
|
118
131
|
});
|
|
119
132
|
|
|
@@ -134,11 +147,14 @@ describe("gating", () => {
|
|
|
134
147
|
});
|
|
135
148
|
});
|
|
136
149
|
|
|
150
|
+
// Canonical Studio decofile-API pointer prefix (authority + path, no version).
|
|
151
|
+
const P = "studio.decocms.com/api/fila/decofile/vm-1/main?token=tok.abc";
|
|
152
|
+
|
|
137
153
|
describe("resolveDraftDecofile", () => {
|
|
138
|
-
it("fetches
|
|
154
|
+
it("fetches the token's path on its validated origin", async () => {
|
|
139
155
|
const calls: string[] = [];
|
|
140
156
|
const blocks = await resolveDraftDecofile({
|
|
141
|
-
pointer:
|
|
157
|
+
pointer: `${P}@v1`,
|
|
142
158
|
env: ENV_ON,
|
|
143
159
|
fetchImpl: (async (url: string) => {
|
|
144
160
|
calls.push(String(url));
|
|
@@ -147,15 +163,18 @@ describe("resolveDraftDecofile", () => {
|
|
|
147
163
|
});
|
|
148
164
|
|
|
149
165
|
expect(blocks).toEqual({ "pages-home": { title: "draft" } });
|
|
166
|
+
// The pointer's version rides along as `v=`, making the URL fully
|
|
167
|
+
// content-addressed so Studio can answer with CDN-cacheable immutable
|
|
168
|
+
// headers when it matches the served sha.
|
|
150
169
|
expect(calls).toEqual([
|
|
151
|
-
"https://
|
|
170
|
+
"https://studio.decocms.com/api/fila/decofile/vm-1/main?token=tok.abc&v=v1",
|
|
152
171
|
]);
|
|
153
172
|
});
|
|
154
173
|
|
|
155
174
|
it("is inert without a host allowlist — no fetch at all", async () => {
|
|
156
175
|
let called = false;
|
|
157
176
|
const blocks = await resolveDraftDecofile({
|
|
158
|
-
pointer:
|
|
177
|
+
pointer: `${P}@v1`,
|
|
159
178
|
env: {},
|
|
160
179
|
fetchImpl: (async () => {
|
|
161
180
|
called = true;
|
|
@@ -166,18 +185,32 @@ describe("resolveDraftDecofile", () => {
|
|
|
166
185
|
expect(called).toBe(false);
|
|
167
186
|
});
|
|
168
187
|
|
|
169
|
-
it("refuses a parseable token whose origin no domain admits — no fetch", async () => {
|
|
188
|
+
it("refuses a parseable token whose origin no domain admits — no fetch, no cache", async () => {
|
|
170
189
|
let called = false;
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
190
|
+
const fetchImpl = (async () => {
|
|
191
|
+
called = true;
|
|
192
|
+
return jsonResponse({});
|
|
193
|
+
}) as unknown as typeof fetch;
|
|
194
|
+
expect(
|
|
195
|
+
await resolveDraftDecofile({
|
|
196
|
+
pointer: "abc.evil.example/x@v1",
|
|
197
|
+
env: ENV_ON,
|
|
198
|
+
fetchImpl,
|
|
199
|
+
}),
|
|
200
|
+
).toBeNull();
|
|
180
201
|
expect(called).toBe(false);
|
|
202
|
+
|
|
203
|
+
// Origin validation runs BEFORE the cache: a version already cached from
|
|
204
|
+
// an allowed origin must not be served for a disallowed authority.
|
|
205
|
+
await resolveDraftDecofile({ pointer: `${P}@vX`, env: ENV_ON, fetchImpl });
|
|
206
|
+
expect(called).toBe(true);
|
|
207
|
+
expect(
|
|
208
|
+
await resolveDraftDecofile({
|
|
209
|
+
pointer: "abc.evil.example/x@vX",
|
|
210
|
+
env: ENV_ON,
|
|
211
|
+
fetchImpl,
|
|
212
|
+
}),
|
|
213
|
+
).toBeNull();
|
|
181
214
|
});
|
|
182
215
|
|
|
183
216
|
it("caches by version — one fetch per version, not per request", async () => {
|
|
@@ -186,7 +219,6 @@ describe("resolveDraftDecofile", () => {
|
|
|
186
219
|
fetches++;
|
|
187
220
|
return jsonResponse({ n: fetches });
|
|
188
221
|
}) as unknown as typeof fetch;
|
|
189
|
-
const P = "abc.preview-studio.decocms.com";
|
|
190
222
|
|
|
191
223
|
const a = await resolveDraftDecofile({
|
|
192
224
|
pointer: `${P}@v1`,
|
|
@@ -211,7 +243,6 @@ describe("resolveDraftDecofile", () => {
|
|
|
211
243
|
fetches++;
|
|
212
244
|
return jsonResponse({ n: fetches });
|
|
213
245
|
}) as unknown as typeof fetch;
|
|
214
|
-
const P = "abc.preview-studio.decocms.com";
|
|
215
246
|
|
|
216
247
|
for (const v of ["v1", "v2", "v3", "v4"]) {
|
|
217
248
|
await resolveDraftDecofile({
|
|
@@ -228,7 +259,6 @@ describe("resolveDraftDecofile", () => {
|
|
|
228
259
|
});
|
|
229
260
|
|
|
230
261
|
it("degrades to published on unreachable / non-2xx / unparseable", async () => {
|
|
231
|
-
const P = "abc.preview-studio.decocms.com";
|
|
232
262
|
for (const fetchImpl of [
|
|
233
263
|
async () => {
|
|
234
264
|
throw new Error("ECONNREFUSED");
|
|
@@ -254,12 +284,13 @@ describe("resolveDraftDecofile", () => {
|
|
|
254
284
|
|
|
255
285
|
// DEFAULT_PREVIEW_API_DOMAINS is part of the public contract — pin it.
|
|
256
286
|
describe("DEFAULT_PREVIEW_API_DOMAINS", () => {
|
|
257
|
-
it("ships the deco-operated origins
|
|
287
|
+
it("ships the deco-operated origins plus local-dev exact hosts", () => {
|
|
258
288
|
expect(DEFAULT_PREVIEW_API_DOMAINS).toEqual([
|
|
259
|
-
".
|
|
260
|
-
"
|
|
261
|
-
".
|
|
289
|
+
"local.studio.decocms.com",
|
|
290
|
+
"localhost",
|
|
291
|
+
"127.0.0.1",
|
|
262
292
|
".localhost",
|
|
293
|
+
".decocms.com",
|
|
263
294
|
]);
|
|
264
295
|
});
|
|
265
296
|
});
|
|
@@ -337,7 +368,7 @@ describe("resolveDraftForRequest", () => {
|
|
|
337
368
|
method: "POST",
|
|
338
369
|
headers: {
|
|
339
370
|
"x-forwarded-host": host,
|
|
340
|
-
cookie:
|
|
371
|
+
cookie: `__deco_draft=${encodeURIComponent(`${P}@v1`)}`,
|
|
341
372
|
},
|
|
342
373
|
},
|
|
343
374
|
);
|
package/src/cms/draftSource.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Draft preview — pull-based decofile
|
|
2
|
+
* Draft preview — pull-based decofile snapshot.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Studio serves the draft decofile (the merged `.deco/blocks/*.json` at the
|
|
5
|
+
* branch head) from its decofile API
|
|
6
|
+
* (`GET <origin>/api/<org>/decofile/<virtualMcpId>/<branch>?token=…`); a
|
|
7
|
+
* production site pulls it and renders its own real pages against it. This
|
|
8
|
+
* replaces pushing the decofile into a POST body, which only deco's own
|
|
9
|
+
* runtime honours — Next.js and most frameworks render on GET only.
|
|
9
10
|
*
|
|
10
11
|
* This module is the framework-agnostic half: token parsing, origin
|
|
11
12
|
* validation, fetching, and version caching. Binding a resolved draft to a
|
|
@@ -23,60 +24,83 @@
|
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
* A parsed `?__draft=` token: `<host[:port]>@<version>`.
|
|
27
|
+
* A parsed `?__draft=` token: `<host[:port]><path[?query]>@<version>`.
|
|
27
28
|
*
|
|
28
|
-
* The token carries the AUTHORITY of the draft content API, never a
|
|
29
|
-
*
|
|
30
|
-
* the matched domain instead.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* The token carries the AUTHORITY + PATH of the draft content API, never a
|
|
30
|
+
* scheme — a full URL would be an SSRF vector, and the scheme is derived from
|
|
31
|
+
* the matched domain instead. The path is REQUIRED and typically addresses
|
|
32
|
+
* Studio's decofile API (`/api/<org>/decofile/<virtualMcpId>/<branch>?token=…`);
|
|
33
|
+
* its query carries the signed draft grant.
|
|
33
34
|
*/
|
|
34
35
|
export interface DraftPointer {
|
|
35
|
-
/** Content-API authority, e.g. `
|
|
36
|
+
/** Content-API authority, e.g. `studio.decocms.com` or `localhost:4000`. */
|
|
36
37
|
host: string;
|
|
37
|
-
/**
|
|
38
|
+
/** Path (+ query) on that authority serving the decofile JSON. */
|
|
39
|
+
path: string;
|
|
40
|
+
/** Opaque content version (the branch head sha / server ETag). Immutable → safe cache key. */
|
|
38
41
|
version: string;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
/** Lowercase DNS hostname
|
|
44
|
+
/** Lowercase DNS hostname. A single label is allowed — exact-host domain
|
|
45
|
+
* entries (`localhost`, `local.studio.decocms.com`) can admit it. */
|
|
42
46
|
const HOST_RE =
|
|
43
|
-
/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)
|
|
47
|
+
/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
|
44
48
|
const PORT_RE = /^[0-9]{1,5}$/;
|
|
45
49
|
const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
50
|
+
/** Rooted path with an optional query; conservative charset, no `@`/`#`/space. */
|
|
51
|
+
const PATH_RE = /^\/[A-Za-z0-9/_.%~=&?-]*$/;
|
|
46
52
|
|
|
47
53
|
/**
|
|
48
|
-
* Parse `<host[:port]>@<version>`. Null on anything unexpected — callers
|
|
49
|
-
* back to published content.
|
|
50
|
-
*
|
|
54
|
+
* Parse `<host[:port]><path>@<version>`. Null on anything unexpected — callers
|
|
55
|
+
* fall back to published content. Splits on the LAST `@` (neither the path
|
|
56
|
+
* charset nor a signed token may contain one, so a stray `@` fails validation
|
|
57
|
+
* rather than being half-read).
|
|
51
58
|
*/
|
|
52
59
|
export function parseDraftPointer(
|
|
53
60
|
raw: string | null | undefined,
|
|
54
61
|
): DraftPointer | null {
|
|
55
62
|
if (!raw) return null;
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
const at = raw.lastIndexOf("@");
|
|
64
|
+
if (at <= 0 || at === raw.length - 1) return null;
|
|
65
|
+
const authorityAndPath = raw.slice(0, at);
|
|
66
|
+
const version = raw.slice(at + 1);
|
|
67
|
+
if (!VERSION_RE.test(version)) return null;
|
|
68
|
+
|
|
69
|
+
const slash = authorityAndPath.indexOf("/");
|
|
70
|
+
if (slash === -1) return null;
|
|
71
|
+
const authority = authorityAndPath.slice(0, slash).toLowerCase();
|
|
72
|
+
const path = authorityAndPath.slice(slash);
|
|
73
|
+
if (!PATH_RE.test(path)) return null;
|
|
74
|
+
|
|
75
|
+
const [host, port, extra] = authority.split(":");
|
|
62
76
|
if (extra !== undefined) return null;
|
|
63
77
|
if (!host || !HOST_RE.test(host)) return null;
|
|
64
78
|
if (port !== undefined && !PORT_RE.test(port)) return null;
|
|
65
79
|
|
|
66
|
-
return {
|
|
80
|
+
return {
|
|
81
|
+
host: port === undefined ? host : `${host}:${port}`,
|
|
82
|
+
path,
|
|
83
|
+
version,
|
|
84
|
+
};
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
/**
|
|
70
88
|
* Domains the draft content API may live under — deco-operated, so shipping
|
|
71
89
|
* them as defaults adds no SSRF surface. `DECO_PREVIEW_API_DOMAINS` overrides
|
|
72
|
-
* the whole list when set.
|
|
73
|
-
*
|
|
90
|
+
* the whole list when set.
|
|
91
|
+
*
|
|
92
|
+
* Two entry shapes: a dot-prefixed entry is a suffix match with a guaranteed
|
|
93
|
+
* label boundary (`evil-decocms.com` cannot pass `.decocms.com`); a bare entry
|
|
94
|
+
* is an exact-host match (needed for `localhost` and dev origins, which no
|
|
95
|
+
* suffix can admit). Order matters only for the local/port rule: the first
|
|
96
|
+
* matching entry decides whether a port and `http` are allowed.
|
|
74
97
|
*/
|
|
75
98
|
export const DEFAULT_PREVIEW_API_DOMAINS = [
|
|
76
|
-
".
|
|
77
|
-
"
|
|
78
|
-
".
|
|
99
|
+
"local.studio.decocms.com", // native/web dev origin (http, explicit port)
|
|
100
|
+
"localhost",
|
|
101
|
+
"127.0.0.1", // loopback dev — `localhost` can resolve to a different (IPv6) server
|
|
79
102
|
".localhost",
|
|
103
|
+
".decocms.com", // hosted Studio (decofile API) + preview daemons
|
|
80
104
|
];
|
|
81
105
|
|
|
82
106
|
function readApiDomains(env: Record<string, string | undefined>): string[] {
|
|
@@ -103,13 +127,26 @@ export function previewApiOriginForHost(
|
|
|
103
127
|
): string | null {
|
|
104
128
|
const [host, port] = authority.toLowerCase().split(":");
|
|
105
129
|
if (!host) return null;
|
|
106
|
-
const domain = readApiDomains(envOrProcess(env)).find(
|
|
107
|
-
(
|
|
130
|
+
const domain = readApiDomains(envOrProcess(env)).find((d) =>
|
|
131
|
+
d.startsWith(".") ? host.length > d.length && host.endsWith(d) : host === d,
|
|
108
132
|
);
|
|
109
133
|
if (!domain) return null;
|
|
110
|
-
|
|
134
|
+
// Local entries may carry an explicit port; public domains may not (a
|
|
135
|
+
// public-domain token must not steer the fetch at odd ports).
|
|
136
|
+
const local =
|
|
137
|
+
domain === "localhost" ||
|
|
138
|
+
domain === "127.0.0.1" ||
|
|
139
|
+
domain.endsWith(".localhost") ||
|
|
140
|
+
domain === "local.studio.decocms.com";
|
|
111
141
|
if (port !== undefined && !local) return null;
|
|
112
|
-
|
|
142
|
+
// Scheme is derived, never taken from the token. Plain-loopback dev hosts
|
|
143
|
+
// are http; local.studio.decocms.com is the native app's TLS dev origin
|
|
144
|
+
// (locally-trusted cert), so it — like every public domain — is https.
|
|
145
|
+
const insecure =
|
|
146
|
+
domain === "localhost" ||
|
|
147
|
+
domain === "127.0.0.1" ||
|
|
148
|
+
domain.endsWith(".localhost");
|
|
149
|
+
return `${insecure ? "http" : "https"}://${host}${port === undefined ? "" : `:${port}`}`;
|
|
113
150
|
}
|
|
114
151
|
|
|
115
152
|
/**
|
|
@@ -217,7 +254,7 @@ export function clearDraftCache(): void {
|
|
|
217
254
|
}
|
|
218
255
|
|
|
219
256
|
export interface ResolveDraftOptions {
|
|
220
|
-
/** Raw `<host[:port]>@<version>` token from the request. */
|
|
257
|
+
/** Raw `<host[:port]><path>@<version>` token from the request. */
|
|
221
258
|
pointer: string | null | undefined;
|
|
222
259
|
/** Defaults to `process.env`. */
|
|
223
260
|
env?: Record<string, string | undefined>;
|
|
@@ -241,16 +278,28 @@ export async function resolveDraftDecofile(
|
|
|
241
278
|
const parsed = parseDraftPointer(options.pointer);
|
|
242
279
|
if (!parsed) return null;
|
|
243
280
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
281
|
+
// Origin validation BEFORE the cache: a cached version must never be served
|
|
282
|
+
// for a pointer whose authority the configuration would reject.
|
|
247
283
|
const origin = previewApiOriginForHost(parsed.host, env);
|
|
248
284
|
if (!origin) return null;
|
|
249
285
|
|
|
286
|
+
const cached = byVersion.get(parsed.version);
|
|
287
|
+
if (cached) return cached;
|
|
288
|
+
|
|
250
289
|
const doFetch = options.fetchImpl ?? fetch;
|
|
290
|
+
// The pointer's version rides along as `v=`, making the fetch URL fully
|
|
291
|
+
// content-addressed (path + token + version). Today the server serves it
|
|
292
|
+
// no-store either way — token-protected drafts are deliberately NOT
|
|
293
|
+
// shared-cacheable (edge caches can't re-validate the grant, so revocation
|
|
294
|
+
// wouldn't propagate). The param still earns its place: version-tagged
|
|
295
|
+
// access logs, and it's the prerequisite for edge-validated caching later
|
|
296
|
+
// (a CDN worker checking the token per request, keyed on path+version)
|
|
297
|
+
// without another runtime deploy.
|
|
298
|
+
const url = new URL(parsed.path, origin);
|
|
299
|
+
url.searchParams.append("v", parsed.version);
|
|
251
300
|
let res: Response;
|
|
252
301
|
try {
|
|
253
|
-
res = await doFetch(
|
|
302
|
+
res = await doFetch(url.toString(), { cache: "no-store" });
|
|
254
303
|
} catch {
|
|
255
304
|
return null;
|
|
256
305
|
}
|
|
@@ -355,7 +404,12 @@ export async function resolveDraftForRequest(
|
|
|
355
404
|
|
|
356
405
|
type DraftOverrideGetter = () => Record<string, unknown> | null | undefined;
|
|
357
406
|
|
|
358
|
-
|
|
407
|
+
// globalThis-backed like the hosts above: bundlers can duplicate this module
|
|
408
|
+
// across graphs (nested node_modules installs, RSC layer splits), and a getter
|
|
409
|
+
// registered on one instance's module variable is invisible to the copy
|
|
410
|
+
// `loadBlocks()` imports — the framework binding then resolves the draft while
|
|
411
|
+
// the render silently serves published content.
|
|
412
|
+
const GG = globalThis as { __decoDraftOverrideGetter?: DraftOverrideGetter };
|
|
359
413
|
|
|
360
414
|
/**
|
|
361
415
|
* Inject the request-scoped draft getter.
|
|
@@ -366,7 +420,7 @@ let getDraftOverride: DraftOverrideGetter = () => undefined;
|
|
|
366
420
|
* Never called → returns undefined → `loadBlocks()` behaves exactly as before.
|
|
367
421
|
*/
|
|
368
422
|
export function setDraftOverrideGetter(getter: DraftOverrideGetter): void {
|
|
369
|
-
|
|
423
|
+
GG.__decoDraftOverrideGetter = getter;
|
|
370
424
|
}
|
|
371
425
|
|
|
372
426
|
/** The current request's draft blocks, if a binding registered one. */
|
|
@@ -374,5 +428,5 @@ export function getRequestDraftOverride():
|
|
|
374
428
|
| Record<string, unknown>
|
|
375
429
|
| null
|
|
376
430
|
| undefined {
|
|
377
|
-
return
|
|
431
|
+
return GG.__decoDraftOverrideGetter?.();
|
|
378
432
|
}
|
package/src/cms/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
export type {
|
|
1
|
+
export type {
|
|
2
|
+
ApplySectionConventionsInput,
|
|
3
|
+
SectionMetaEntry,
|
|
4
|
+
} from "./applySectionConventions";
|
|
2
5
|
export { applySectionConventions } from "./applySectionConventions";
|
|
3
6
|
export type { BlockSnapshot, BlockSource, KVNamespace } from "./blockSource";
|
|
4
7
|
export {
|
|
@@ -43,8 +46,13 @@ export {
|
|
|
43
46
|
onChange,
|
|
44
47
|
setBlocks,
|
|
45
48
|
withBlocksOverride,
|
|
49
|
+
withDraftBlocks,
|
|
46
50
|
} from "./loader";
|
|
47
|
-
export type {
|
|
51
|
+
export type {
|
|
52
|
+
OnBeforeResolveProps,
|
|
53
|
+
SectionModule,
|
|
54
|
+
SectionOptions,
|
|
55
|
+
} from "./registry";
|
|
48
56
|
export {
|
|
49
57
|
getResolvedComponent,
|
|
50
58
|
getSection,
|
package/src/cms/loader.test.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
2
|
import { setDraftOverrideGetter } from "./draftSource";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
findPageByPath,
|
|
5
|
+
loadBlocks,
|
|
6
|
+
matchPath,
|
|
7
|
+
setBlocks,
|
|
8
|
+
withBlocksOverride,
|
|
9
|
+
withDraftBlocks,
|
|
10
|
+
} from "./loader";
|
|
4
11
|
|
|
5
12
|
// Mirrors the behavior of the original deco-cx/deco Fresh framework
|
|
6
13
|
// (runtime/features/render.tsx), which uses native `URLPattern` directly
|
|
@@ -28,7 +35,9 @@ describe("matchPath", () => {
|
|
|
28
35
|
|
|
29
36
|
describe("named params (:slug)", () => {
|
|
30
37
|
it("captures a single param", () => {
|
|
31
|
-
expect(matchPath("/foo/:slug", "/foo/sabonete")).toEqual({
|
|
38
|
+
expect(matchPath("/foo/:slug", "/foo/sabonete")).toEqual({
|
|
39
|
+
slug: "sabonete",
|
|
40
|
+
});
|
|
32
41
|
});
|
|
33
42
|
|
|
34
43
|
it("captures a param sandwiched between literals (VTEX PDP)", () => {
|
|
@@ -75,7 +84,9 @@ describe("matchPath", () => {
|
|
|
75
84
|
});
|
|
76
85
|
|
|
77
86
|
it("matches with the optional group absent", () => {
|
|
78
|
-
expect(matchPath("/{granado/}?*", "/perfumaria")).toEqual({
|
|
87
|
+
expect(matchPath("/{granado/}?*", "/perfumaria")).toEqual({
|
|
88
|
+
"0": "perfumaria",
|
|
89
|
+
});
|
|
79
90
|
});
|
|
80
91
|
|
|
81
92
|
it("matches root when optional prefix and splat collapse to empty", () => {
|
|
@@ -83,17 +94,26 @@ describe("matchPath", () => {
|
|
|
83
94
|
});
|
|
84
95
|
|
|
85
96
|
it("matches with an optional prefix before a literal segment", () => {
|
|
86
|
-
expect(
|
|
97
|
+
expect(
|
|
98
|
+
matchPath(
|
|
99
|
+
"/{granado/}?campanhas/*",
|
|
100
|
+
"/granado/campanhas/destaques-2023",
|
|
101
|
+
),
|
|
102
|
+
).toEqual({
|
|
87
103
|
"0": "destaques-2023",
|
|
88
104
|
});
|
|
89
|
-
expect(
|
|
105
|
+
expect(
|
|
106
|
+
matchPath("/{granado/}?campanhas/*", "/campanhas/destaques-2023"),
|
|
107
|
+
).toEqual({
|
|
90
108
|
"0": "destaques-2023",
|
|
91
109
|
});
|
|
92
110
|
});
|
|
93
111
|
|
|
94
112
|
it("matches an optional suffix group present and absent", () => {
|
|
95
113
|
expect(matchPath("/black-friday{/70-off}?", "/black-friday")).toEqual({});
|
|
96
|
-
expect(
|
|
114
|
+
expect(
|
|
115
|
+
matchPath("/black-friday{/70-off}?", "/black-friday/70-off"),
|
|
116
|
+
).toEqual({});
|
|
97
117
|
});
|
|
98
118
|
});
|
|
99
119
|
|
|
@@ -113,7 +133,9 @@ describe("matchPath", () => {
|
|
|
113
133
|
// biome-ignore lint/performance/noDelete: restoring exact global state
|
|
114
134
|
delete g.URLPattern;
|
|
115
135
|
try {
|
|
116
|
-
expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow(
|
|
136
|
+
expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow(
|
|
137
|
+
/URLPattern.*Node\.js >= 24/s,
|
|
138
|
+
);
|
|
117
139
|
} finally {
|
|
118
140
|
if (saved !== undefined) g.URLPattern = saved;
|
|
119
141
|
}
|
|
@@ -202,12 +224,11 @@ describe("findPageByPath specificity", () => {
|
|
|
202
224
|
|
|
203
225
|
describe("loadBlocks draft override — key percent-encoding", () => {
|
|
204
226
|
// The published decofile encodes special characters in block keys
|
|
205
|
-
// (`pages-Home%20(principal)-1`); the Studio draft
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
// made ~73% of casaevideo's pages (home + afiliados included) ignore drafts.
|
|
227
|
+
// (`pages-Home%20(principal)-1`); the Studio draft emits them raw
|
|
228
|
+
// (`pages-Home (principal)-1`). Under snapshot semantics the draft REPLACES
|
|
229
|
+
// the file-backed base wholesale, so an encoded/raw twin pair can never
|
|
230
|
+
// coexist — these regression tests (from the merge era, when ~73% of
|
|
231
|
+
// casaevideo's pages silently ignored drafts) now pin that property.
|
|
211
232
|
|
|
212
233
|
afterEach(() => {
|
|
213
234
|
setBlocks({});
|
|
@@ -281,3 +302,76 @@ describe("loadBlocks draft override — key percent-encoding", () => {
|
|
|
281
302
|
expect(pageBlocks).toHaveLength(0);
|
|
282
303
|
});
|
|
283
304
|
});
|
|
305
|
+
|
|
306
|
+
describe("loadBlocks draft snapshot semantics", () => {
|
|
307
|
+
afterEach(() => {
|
|
308
|
+
setBlocks({});
|
|
309
|
+
setDraftOverrideGetter(() => undefined);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("a block absent from the draft is deleted — the draft is the complete truth", () => {
|
|
313
|
+
setBlocks({
|
|
314
|
+
"pages-home-1": { name: "Home", path: "/", sections: [{}] },
|
|
315
|
+
"pages-old-2": { name: "Old", path: "/old", sections: [{}] },
|
|
316
|
+
});
|
|
317
|
+
setDraftOverrideGetter(() => ({
|
|
318
|
+
"pages-home-1": { name: "Home", path: "/", sections: [{}] },
|
|
319
|
+
}));
|
|
320
|
+
|
|
321
|
+
const keys = Object.keys(loadBlocks());
|
|
322
|
+
expect(keys).toEqual(["pages-home-1"]);
|
|
323
|
+
expect(findPageByPath("/old")).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("synthetic CSV-redirect base blocks survive the snapshot", () => {
|
|
327
|
+
setBlocks({
|
|
328
|
+
"__csv_redirects__bulk.csv": { redirects: [{ from: "/a", to: "/b" }] },
|
|
329
|
+
"pages-home-1": { name: "Home", path: "/", sections: [{}] },
|
|
330
|
+
});
|
|
331
|
+
setDraftOverrideGetter(() => ({
|
|
332
|
+
"pages-home-1": { name: "Home", path: "/", sections: [{}] },
|
|
333
|
+
}));
|
|
334
|
+
|
|
335
|
+
const blocks = loadBlocks();
|
|
336
|
+
expect(blocks["__csv_redirects__bulk.csv"]).toEqual({
|
|
337
|
+
redirects: [{ from: "/a", to: "/b" }],
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("withDraftBlocks applies snapshot semantics; withBlocksOverride keeps merge", () => {
|
|
342
|
+
setBlocks({
|
|
343
|
+
"pages-home-1": { name: "Home", path: "/", sections: [{}] },
|
|
344
|
+
"pages-other-2": { name: "Other", path: "/other", sections: [{}] },
|
|
345
|
+
});
|
|
346
|
+
const draft = {
|
|
347
|
+
"pages-home-1": { name: "Home", path: "/", sections: [{}] },
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Snapshot: the base-only block is gone.
|
|
351
|
+
withDraftBlocks(draft, () => {
|
|
352
|
+
expect(Object.keys(loadBlocks())).toEqual(["pages-home-1"]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Merge (admin partial payloads): the base-only block survives.
|
|
356
|
+
withBlocksOverride(draft, () => {
|
|
357
|
+
expect(Object.keys(loadBlocks()).sort()).toEqual([
|
|
358
|
+
"pages-home-1",
|
|
359
|
+
"pages-other-2",
|
|
360
|
+
]);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("an explicit scope wins over the ambient draft", () => {
|
|
365
|
+
setBlocks({ "pages-home-1": { name: "Home", path: "/", sections: [{}] } });
|
|
366
|
+
setDraftOverrideGetter(() => ({
|
|
367
|
+
"pages-ambient-9": { name: "A", path: "/a", sections: [{}] },
|
|
368
|
+
}));
|
|
369
|
+
|
|
370
|
+
withDraftBlocks(
|
|
371
|
+
{ "pages-scoped-3": { name: "S", path: "/s", sections: [{}] } },
|
|
372
|
+
() => {
|
|
373
|
+
expect(Object.keys(loadBlocks())).toEqual(["pages-scoped-3"]);
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
});
|
package/src/cms/loader.ts
CHANGED
|
@@ -31,7 +31,21 @@ interface ALSLike<T> {
|
|
|
31
31
|
// node:async_hooks with an empty shim). The namespace import avoids Rollup's
|
|
32
32
|
// named-export validation, and the runtime check prevents construction errors.
|
|
33
33
|
const ALS = (asyncHooks as any).AsyncLocalStorage;
|
|
34
|
-
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A scoped blocks override, tagged with how it composes over the base:
|
|
37
|
+
* - `merge`: a PARTIAL decofile (admin preview payloads) — entries replace or
|
|
38
|
+
* delete their base twins, everything else survives (see mergeOverride).
|
|
39
|
+
* - `snapshot`: a COMPLETE decofile (draft preview) — it replaces the
|
|
40
|
+
* file-backed base entirely; only synthetic base blocks survive (see
|
|
41
|
+
* applyDraftSnapshot).
|
|
42
|
+
*/
|
|
43
|
+
interface BlocksOverride {
|
|
44
|
+
mode: "merge" | "snapshot";
|
|
45
|
+
blocks: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const blocksOverrideStorage: ALSLike<BlocksOverride> = ALS
|
|
35
49
|
? new ALS()
|
|
36
50
|
: { getStore: () => undefined, run: (_s: any, fn: any) => fn() };
|
|
37
51
|
|
|
@@ -39,7 +53,10 @@ const blocksOverrideStorage: ALSLike<Record<string, unknown>> = ALS
|
|
|
39
53
|
// Change listeners
|
|
40
54
|
// ---------------------------------------------------------------------------
|
|
41
55
|
|
|
42
|
-
type ChangeListener = (
|
|
56
|
+
type ChangeListener = (
|
|
57
|
+
blocks: Record<string, unknown>,
|
|
58
|
+
revision: string,
|
|
59
|
+
) => void;
|
|
43
60
|
const changeListeners: ChangeListener[] = [];
|
|
44
61
|
|
|
45
62
|
/** Register a callback invoked whenever setBlocks() changes the decofile. */
|
|
@@ -139,9 +156,40 @@ function mergeOverride(
|
|
|
139
156
|
}
|
|
140
157
|
|
|
141
158
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
159
|
+
* Base blocks that are NOT file-backed: synthesized by the generator (CSV
|
|
160
|
+
* redirect loaders, keyed `__csv_redirects__<file>`). A draft snapshot is the
|
|
161
|
+
* complete truth of `.deco/blocks/` but knows nothing about these, so they
|
|
162
|
+
* survive the snapshot instead of vanishing from the preview.
|
|
163
|
+
*/
|
|
164
|
+
const SYNTHETIC_BLOCK_KEY_PREFIX = "__csv_redirects__";
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Compose a draft SNAPSHOT over the base blocks: the draft is the complete
|
|
168
|
+
* decofile at the branch head, so it REPLACES every file-backed base block —
|
|
169
|
+
* a block absent from the draft was deleted and must not render. Only
|
|
170
|
+
* synthetic base blocks (see SYNTHETIC_BLOCK_KEY_PREFIX) survive. `null`
|
|
171
|
+
* draft values are tolerated as deletions for defensiveness.
|
|
172
|
+
*/
|
|
173
|
+
function applyDraftSnapshot(
|
|
174
|
+
base: Record<string, unknown>,
|
|
175
|
+
draft: Record<string, unknown>,
|
|
176
|
+
): Record<string, unknown> {
|
|
177
|
+
const out: Record<string, unknown> = {};
|
|
178
|
+
for (const [key, value] of Object.entries(base)) {
|
|
179
|
+
if (key.startsWith(SYNTHETIC_BLOCK_KEY_PREFIX)) out[key] = value;
|
|
180
|
+
}
|
|
181
|
+
for (const [key, value] of Object.entries(draft)) {
|
|
182
|
+
if (value === null || value === undefined) continue;
|
|
183
|
+
out[key] = value;
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Load the current blocks. If running inside a `withBlocksOverride` /
|
|
190
|
+
* `withDraftBlocks` scope (admin preview / draft endpoints) or a request
|
|
191
|
+
* carrying an ambient draft, that override composes over the base blocks
|
|
192
|
+
* according to its mode.
|
|
145
193
|
*/
|
|
146
194
|
export function loadBlocks(): Record<string, unknown> {
|
|
147
195
|
// Re-sync from globalThis in case setBlocks was called in another module instance
|
|
@@ -150,11 +198,17 @@ export function loadBlocks(): Record<string, unknown> {
|
|
|
150
198
|
revision = G.__deco.revision ?? null;
|
|
151
199
|
}
|
|
152
200
|
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
201
|
+
// An explicit scope (the caller named the exact blocks to render) wins over
|
|
202
|
+
// an ambient draft: a draft pointer on the same request must not silently
|
|
203
|
+
// replace it.
|
|
204
|
+
const scoped = blocksOverrideStorage.getStore();
|
|
205
|
+
if (scoped) {
|
|
206
|
+
return scoped.mode === "merge"
|
|
207
|
+
? mergeOverride(blockData, scoped.blocks)
|
|
208
|
+
: applyDraftSnapshot(blockData, scoped.blocks);
|
|
209
|
+
}
|
|
210
|
+
const draft = getRequestDraftOverride();
|
|
211
|
+
if (draft) return applyDraftSnapshot(blockData, draft);
|
|
158
212
|
return blockData;
|
|
159
213
|
}
|
|
160
214
|
|
|
@@ -171,8 +225,25 @@ export function getRevision(): string | null {
|
|
|
171
225
|
* for the duration of the render. Other concurrent requests are not
|
|
172
226
|
* affected (AsyncLocalStorage is per-request scoped).
|
|
173
227
|
*/
|
|
174
|
-
export function withBlocksOverride<T>(
|
|
175
|
-
|
|
228
|
+
export function withBlocksOverride<T>(
|
|
229
|
+
override: Record<string, unknown>,
|
|
230
|
+
fn: () => T,
|
|
231
|
+
): T {
|
|
232
|
+
return blocksOverrideStorage.run({ mode: "merge", blocks: override }, fn);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Run a function with a COMPLETE draft decofile (snapshot semantics — see
|
|
237
|
+
* applyDraftSnapshot). Used by secondary endpoints (`/deco/invoke`) that
|
|
238
|
+
* re-resolve the page's draft from the raw request: the draft must compose
|
|
239
|
+
* exactly like the page render, deletions included, or a lazy section could
|
|
240
|
+
* render a block the page no longer has.
|
|
241
|
+
*/
|
|
242
|
+
export function withDraftBlocks<T>(
|
|
243
|
+
draft: Record<string, unknown>,
|
|
244
|
+
fn: () => T,
|
|
245
|
+
): T {
|
|
246
|
+
return blocksOverrideStorage.run({ mode: "snapshot", blocks: draft }, fn);
|
|
176
247
|
}
|
|
177
248
|
|
|
178
249
|
// Higher key wins. Compared lexicographically:
|
|
@@ -213,7 +284,11 @@ function pathSpecificityKey(path: string): [number, number, number] {
|
|
|
213
284
|
|
|
214
285
|
export function getAllPages(): Array<{ key: string; page: DecoPage }> {
|
|
215
286
|
const blocks = loadBlocks();
|
|
216
|
-
const pages: Array<{
|
|
287
|
+
const pages: Array<{
|
|
288
|
+
key: string;
|
|
289
|
+
page: DecoPage;
|
|
290
|
+
key2: [number, number, number];
|
|
291
|
+
}> = [];
|
|
217
292
|
|
|
218
293
|
for (const [key, block] of Object.entries(blocks)) {
|
|
219
294
|
if (!key.startsWith("pages-")) continue;
|
|
@@ -281,7 +356,10 @@ declare const URLPattern: {
|
|
|
281
356
|
* `URLPattern` is native in browsers, workerd, Deno, and Node >= 24 (this
|
|
282
357
|
* package's `engines` floor). Node 22 and older lack it.
|
|
283
358
|
*/
|
|
284
|
-
export function matchPath(
|
|
359
|
+
export function matchPath(
|
|
360
|
+
pattern: string,
|
|
361
|
+
urlPath: string,
|
|
362
|
+
): Record<string, string> | null {
|
|
285
363
|
if (typeof URLPattern === "undefined") {
|
|
286
364
|
throw new Error(
|
|
287
365
|
"@decocms/blocks: this runtime has no URLPattern Web API, so CMS page " +
|