@decocms/blocks 7.27.0 → 7.28.0-beta.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 +201 -0
- package/src/cms/draftSource.ts +197 -0
- package/src/cms/index.ts +12 -2
- package/src/cms/loader.ts +14 -10
- package/src/cms/schema.test.ts +4 -51
- package/src/cms/schema.ts +19 -38
package/package.json
CHANGED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildDraftOrigin,
|
|
5
|
+
clearDraftCache,
|
|
6
|
+
isDraftPreviewEnabled,
|
|
7
|
+
parseDraftPointer,
|
|
8
|
+
resolveDraftDecofile,
|
|
9
|
+
} from "./draftSource";
|
|
10
|
+
|
|
11
|
+
const ENV_ON = {
|
|
12
|
+
DECO_DRAFT_PREVIEW: "1",
|
|
13
|
+
DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
|
17
|
+
return new Response(JSON.stringify(body), {
|
|
18
|
+
status: 200,
|
|
19
|
+
headers: { "content-type": "application/json" },
|
|
20
|
+
...init,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
clearDraftCache();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("parseDraftPointer", () => {
|
|
29
|
+
it("parses handle@version", () => {
|
|
30
|
+
expect(parseDraftPointer("gimenes-abc123@ff00")).toEqual({
|
|
31
|
+
handle: "gimenes-abc123",
|
|
32
|
+
version: "ff00",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("rejects more than one @", () => {
|
|
37
|
+
// A naive split("@") accepts this and silently uses the first two
|
|
38
|
+
// segments — the exact hole found while spiking the fetch path.
|
|
39
|
+
expect(parseDraftPointer("a@b@c")).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("rejects a handle that could escape the authority", () => {
|
|
43
|
+
expect(parseDraftPointer("evil.com/x@v1")).toBeNull();
|
|
44
|
+
expect(parseDraftPointer("user:pw@v1")).toBeNull();
|
|
45
|
+
expect(parseDraftPointer("a/../b@v1")).toBeNull();
|
|
46
|
+
expect(parseDraftPointer(".leading-dot@v1")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects empty halves and missing input", () => {
|
|
50
|
+
expect(parseDraftPointer("@v1")).toBeNull();
|
|
51
|
+
expect(parseDraftPointer("handle@")).toBeNull();
|
|
52
|
+
expect(parseDraftPointer("handle")).toBeNull();
|
|
53
|
+
expect(parseDraftPointer(null)).toBeNull();
|
|
54
|
+
expect(parseDraftPointer("")).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("buildDraftOrigin", () => {
|
|
59
|
+
it("builds https from the configured suffix", () => {
|
|
60
|
+
expect(buildDraftOrigin("abc", [".preview-studio.decocms.com"])).toBe(
|
|
61
|
+
"https://abc.preview-studio.decocms.com",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("uses http for a localhost suffix (local e2e)", () => {
|
|
66
|
+
expect(buildDraftOrigin("abc", [".localhost:3200"])).toBe("http://abc.localhost:3200");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns null with no configured suffix — never guesses an origin", () => {
|
|
70
|
+
expect(buildDraftOrigin("abc", [])).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("isDraftPreviewEnabled", () => {
|
|
75
|
+
it("needs both the flag and a suffix", () => {
|
|
76
|
+
expect(isDraftPreviewEnabled(ENV_ON)).toBe(true);
|
|
77
|
+
expect(isDraftPreviewEnabled({ DECO_DRAFT_PREVIEW: "1" })).toBe(false);
|
|
78
|
+
expect(
|
|
79
|
+
isDraftPreviewEnabled({
|
|
80
|
+
DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com",
|
|
81
|
+
}),
|
|
82
|
+
).toBe(false);
|
|
83
|
+
expect(isDraftPreviewEnabled({})).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("resolveDraftDecofile", () => {
|
|
88
|
+
it("fetches the sandbox decofile and returns it", async () => {
|
|
89
|
+
const calls: string[] = [];
|
|
90
|
+
const blocks = await resolveDraftDecofile({
|
|
91
|
+
pointer: "abc@v1",
|
|
92
|
+
env: ENV_ON,
|
|
93
|
+
fetchImpl: (async (url: string) => {
|
|
94
|
+
calls.push(String(url));
|
|
95
|
+
return jsonResponse({ "pages-home": { title: "draft" } });
|
|
96
|
+
}) as unknown as typeof fetch,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(blocks).toEqual({ "pages-home": { title: "draft" } });
|
|
100
|
+
expect(calls).toEqual(["https://abc.preview-studio.decocms.com/_sandbox/decofile"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("is inert unless explicitly enabled — no fetch at all", async () => {
|
|
104
|
+
let called = false;
|
|
105
|
+
const blocks = await resolveDraftDecofile({
|
|
106
|
+
pointer: "abc@v1",
|
|
107
|
+
env: { DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com" },
|
|
108
|
+
fetchImpl: (async () => {
|
|
109
|
+
called = true;
|
|
110
|
+
return jsonResponse({});
|
|
111
|
+
}) as unknown as typeof fetch,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(blocks).toBeNull();
|
|
115
|
+
expect(called).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("caches by version — one fetch per version, not per request", async () => {
|
|
119
|
+
let fetches = 0;
|
|
120
|
+
const fetchImpl = (async () => {
|
|
121
|
+
fetches++;
|
|
122
|
+
return jsonResponse({ n: fetches });
|
|
123
|
+
}) as unknown as typeof fetch;
|
|
124
|
+
|
|
125
|
+
const a = await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl });
|
|
126
|
+
const b = await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl });
|
|
127
|
+
expect(fetches).toBe(1);
|
|
128
|
+
expect(b).toBe(a);
|
|
129
|
+
|
|
130
|
+
await resolveDraftDecofile({ pointer: "abc@v2", env: ENV_ON, fetchImpl });
|
|
131
|
+
expect(fetches).toBe(2);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("bounds the cache so multi-MB decofiles can't accumulate", async () => {
|
|
135
|
+
let fetches = 0;
|
|
136
|
+
const fetchImpl = (async () => {
|
|
137
|
+
fetches++;
|
|
138
|
+
return jsonResponse({ n: fetches });
|
|
139
|
+
}) as unknown as typeof fetch;
|
|
140
|
+
|
|
141
|
+
for (const v of ["v1", "v2", "v3", "v4"]) {
|
|
142
|
+
await resolveDraftDecofile({ pointer: `abc@${v}`, env: ENV_ON, fetchImpl });
|
|
143
|
+
}
|
|
144
|
+
expect(fetches).toBe(4);
|
|
145
|
+
|
|
146
|
+
// v1 was evicted (cap is 3), so it must re-fetch rather than serve stale.
|
|
147
|
+
await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl });
|
|
148
|
+
expect(fetches).toBe(5);
|
|
149
|
+
|
|
150
|
+
// v4 is still resident.
|
|
151
|
+
await resolveDraftDecofile({ pointer: "abc@v4", env: ENV_ON, fetchImpl });
|
|
152
|
+
expect(fetches).toBe(5);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("degrades to published on a malformed pointer, without fetching", async () => {
|
|
156
|
+
let called = false;
|
|
157
|
+
const blocks = await resolveDraftDecofile({
|
|
158
|
+
pointer: "a@b@c",
|
|
159
|
+
env: ENV_ON,
|
|
160
|
+
fetchImpl: (async () => {
|
|
161
|
+
called = true;
|
|
162
|
+
return jsonResponse({});
|
|
163
|
+
}) as unknown as typeof fetch,
|
|
164
|
+
});
|
|
165
|
+
expect(blocks).toBeNull();
|
|
166
|
+
expect(called).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("degrades to published on a non-2xx sandbox", async () => {
|
|
170
|
+
const blocks = await resolveDraftDecofile({
|
|
171
|
+
pointer: "abc@v1",
|
|
172
|
+
env: ENV_ON,
|
|
173
|
+
fetchImpl: (async () => new Response("nope", { status: 404 })) as unknown as typeof fetch,
|
|
174
|
+
});
|
|
175
|
+
expect(blocks).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("degrades to published when the sandbox is unreachable", async () => {
|
|
179
|
+
const blocks = await resolveDraftDecofile({
|
|
180
|
+
pointer: "abc@v1",
|
|
181
|
+
env: ENV_ON,
|
|
182
|
+
fetchImpl: (async () => {
|
|
183
|
+
throw new Error("ECONNREFUSED");
|
|
184
|
+
}) as unknown as typeof fetch,
|
|
185
|
+
});
|
|
186
|
+
expect(blocks).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("degrades to published on an unparseable body", async () => {
|
|
190
|
+
const blocks = await resolveDraftDecofile({
|
|
191
|
+
pointer: "abc@v1",
|
|
192
|
+
env: ENV_ON,
|
|
193
|
+
fetchImpl: (async () =>
|
|
194
|
+
new Response("<html>not json</html>", {
|
|
195
|
+
status: 200,
|
|
196
|
+
headers: { "content-type": "text/html" },
|
|
197
|
+
})) as unknown as typeof fetch,
|
|
198
|
+
});
|
|
199
|
+
expect(blocks).toBeNull();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft preview — pull-based decofile override.
|
|
3
|
+
*
|
|
4
|
+
* A Studio sandbox 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: pointer parsing, origin
|
|
11
|
+
* construction, 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 BOTH `DECO_DRAFT_PREVIEW=1` and `DECO_SANDBOX_ORIGIN_SUFFIXES`
|
|
18
|
+
* are set, mirroring Fast Deploy's opt-in: upgrading the package must never be
|
|
19
|
+
* enough to start fetching from the network and rendering unpublished content.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A parsed `<handle>@<version>` draft pointer. */
|
|
23
|
+
export interface DraftPointer {
|
|
24
|
+
/** Sandbox handle — the subdomain under a configured origin suffix. */
|
|
25
|
+
handle: string;
|
|
26
|
+
/** Content version (the daemon's ETag). Immutable, so safe to cache on. */
|
|
27
|
+
version: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sandbox handles are `[a-z0-9-]`, always leading with an alphanumeric.
|
|
32
|
+
*
|
|
33
|
+
* Validated BEFORE the handle is interpolated into an authority, so it cannot
|
|
34
|
+
* smuggle `/`, `@`, `:` or userinfo into the URL and redirect the fetch at some
|
|
35
|
+
* other host.
|
|
36
|
+
*/
|
|
37
|
+
const HANDLE_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse `<handle>@<version>`.
|
|
41
|
+
*
|
|
42
|
+
* Requires EXACTLY one `@`: a naive `split("@")` accepts `a@b@c` and silently
|
|
43
|
+
* uses the first two segments, which is how a malformed pointer sneaks past
|
|
44
|
+
* validation. Returns null on anything unexpected — callers fall back to
|
|
45
|
+
* published content.
|
|
46
|
+
*/
|
|
47
|
+
export function parseDraftPointer(raw: string | null | undefined): DraftPointer | null {
|
|
48
|
+
if (!raw) return null;
|
|
49
|
+
const parts = raw.split("@");
|
|
50
|
+
if (parts.length !== 2) return null;
|
|
51
|
+
const [handle, version] = parts;
|
|
52
|
+
if (!handle || !version) return null;
|
|
53
|
+
if (!HANDLE_RE.test(handle)) return null;
|
|
54
|
+
return { handle, version };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Configured suffixes, e.g. `.preview-studio.decocms.com,.localhost:3200`. */
|
|
58
|
+
function readSuffixes(env: Record<string, string | undefined>): string[] {
|
|
59
|
+
return (env.DECO_SANDBOX_ORIGIN_SUFFIXES ?? "")
|
|
60
|
+
.split(",")
|
|
61
|
+
.map((s) => s.trim())
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the sandbox origin for a handle.
|
|
67
|
+
*
|
|
68
|
+
* The origin comes from CONFIGURED suffixes, never from caller input, so there
|
|
69
|
+
* is no SSRF surface to defend and no allowlist to keep correct. A `localhost`
|
|
70
|
+
* suffix (local e2e) speaks http; everything else is https.
|
|
71
|
+
*/
|
|
72
|
+
export function buildDraftOrigin(handle: string, suffixes: string[]): string | null {
|
|
73
|
+
const suffix = suffixes[0];
|
|
74
|
+
if (!suffix) return null;
|
|
75
|
+
if (!HANDLE_RE.test(handle)) return null;
|
|
76
|
+
const scheme = suffix.includes("localhost") ? "http" : "https";
|
|
77
|
+
return `${scheme}://${handle}${suffix}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Version cache.
|
|
82
|
+
*
|
|
83
|
+
* Bounded on purpose: a decofile is routinely multi-megabyte, so an unbounded
|
|
84
|
+
* map keyed by version would grow with every save until the process died.
|
|
85
|
+
* Keyed by version (content-addressed), so a hit is always correct.
|
|
86
|
+
*/
|
|
87
|
+
const MAX_CACHED_VERSIONS = 3;
|
|
88
|
+
const byVersion = new Map<string, Record<string, unknown>>();
|
|
89
|
+
|
|
90
|
+
function cacheDraft(version: string, blocks: Record<string, unknown>): void {
|
|
91
|
+
// Re-insert to make this the most recently used key.
|
|
92
|
+
byVersion.delete(version);
|
|
93
|
+
byVersion.set(version, blocks);
|
|
94
|
+
while (byVersion.size > MAX_CACHED_VERSIONS) {
|
|
95
|
+
const oldest = byVersion.keys().next().value;
|
|
96
|
+
if (oldest === undefined) break;
|
|
97
|
+
byVersion.delete(oldest);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Test seam — drops every cached version. */
|
|
102
|
+
export function clearDraftCache(): void {
|
|
103
|
+
byVersion.clear();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ResolveDraftOptions {
|
|
107
|
+
/** Raw `<handle>@<version>` pointer from the request. */
|
|
108
|
+
pointer: string | null | undefined;
|
|
109
|
+
/** Defaults to `process.env`. */
|
|
110
|
+
env?: Record<string, string | undefined>;
|
|
111
|
+
/** Defaults to global `fetch`. Injected in tests. */
|
|
112
|
+
fetchImpl?: typeof fetch;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve a draft pointer to a decofile, or null to render published content.
|
|
117
|
+
*
|
|
118
|
+
* Null on every failure path — disabled, malformed pointer, unreachable
|
|
119
|
+
* sandbox, non-2xx — because a draft that cannot be resolved must degrade to
|
|
120
|
+
* published rather than break the page. Callers that need to *tell the user*
|
|
121
|
+
* the draft failed should check {@link isDraftPreviewEnabled} and surface it
|
|
122
|
+
* themselves; silently showing published content while the user believes they
|
|
123
|
+
* are looking at a draft is the failure mode worth avoiding.
|
|
124
|
+
*/
|
|
125
|
+
export async function resolveDraftDecofile(
|
|
126
|
+
options: ResolveDraftOptions,
|
|
127
|
+
): Promise<Record<string, unknown> | null> {
|
|
128
|
+
const env =
|
|
129
|
+
options.env ??
|
|
130
|
+
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ??
|
|
131
|
+
{};
|
|
132
|
+
if (env.DECO_DRAFT_PREVIEW !== "1") return null;
|
|
133
|
+
|
|
134
|
+
const parsed = parseDraftPointer(options.pointer);
|
|
135
|
+
if (!parsed) return null;
|
|
136
|
+
|
|
137
|
+
const cached = byVersion.get(parsed.version);
|
|
138
|
+
if (cached) return cached;
|
|
139
|
+
|
|
140
|
+
const origin = buildDraftOrigin(parsed.handle, readSuffixes(env));
|
|
141
|
+
if (!origin) return null;
|
|
142
|
+
|
|
143
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
144
|
+
let res: Response;
|
|
145
|
+
try {
|
|
146
|
+
res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" });
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
if (!res.ok) return null;
|
|
151
|
+
|
|
152
|
+
let blocks: Record<string, unknown>;
|
|
153
|
+
try {
|
|
154
|
+
blocks = (await res.json()) as Record<string, unknown>;
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
cacheDraft(parsed.version, blocks);
|
|
160
|
+
return blocks;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** True when the feature is switched on and configured. Inert otherwise. */
|
|
164
|
+
export function isDraftPreviewEnabled(env?: Record<string, string | undefined>): boolean {
|
|
165
|
+
const e =
|
|
166
|
+
env ??
|
|
167
|
+
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ??
|
|
168
|
+
{};
|
|
169
|
+
return e.DECO_DRAFT_PREVIEW === "1" && readSuffixes(e).length > 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Request binding (dependency-injected by the framework binding)
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
type DraftOverrideGetter = () => Record<string, unknown> | null | undefined;
|
|
177
|
+
|
|
178
|
+
let getDraftOverride: DraftOverrideGetter = () => undefined;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Inject the request-scoped draft getter.
|
|
182
|
+
*
|
|
183
|
+
* Binding a value to "the current request" is framework-specific and `blocks`
|
|
184
|
+
* must not know about any framework: `@decocms/nextjs` backs this with React
|
|
185
|
+
* `cache()` (App Router has no AsyncLocalStorage request scope of its own —
|
|
186
|
+
* `RequestContext.run` is never entered there). Bindings that do have an ALS
|
|
187
|
+
* request scope can back it with that instead. Never called → returns
|
|
188
|
+
* undefined → `loadBlocks()` behaves exactly as before.
|
|
189
|
+
*/
|
|
190
|
+
export function setDraftOverrideGetter(getter: DraftOverrideGetter): void {
|
|
191
|
+
getDraftOverride = getter;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The current request's draft blocks, if a binding registered one. */
|
|
195
|
+
export function getRequestDraftOverride(): Record<string, unknown> | null | undefined {
|
|
196
|
+
return getDraftOverride();
|
|
197
|
+
}
|
package/src/cms/index.ts
CHANGED
|
@@ -12,6 +12,16 @@ export {
|
|
|
12
12
|
revisionKey,
|
|
13
13
|
snapshotKey,
|
|
14
14
|
} from "./blockSource";
|
|
15
|
+
export type { DraftPointer, ResolveDraftOptions } from "./draftSource";
|
|
16
|
+
export {
|
|
17
|
+
buildDraftOrigin,
|
|
18
|
+
clearDraftCache,
|
|
19
|
+
getRequestDraftOverride,
|
|
20
|
+
isDraftPreviewEnabled,
|
|
21
|
+
parseDraftPointer,
|
|
22
|
+
resolveDraftDecofile,
|
|
23
|
+
setDraftOverrideGetter,
|
|
24
|
+
} from "./draftSource";
|
|
15
25
|
export type { DecoPage, Resolvable } from "./loader";
|
|
16
26
|
export {
|
|
17
27
|
findPageByPath,
|
|
@@ -82,8 +92,6 @@ export {
|
|
|
82
92
|
unregisterCommerceLoader,
|
|
83
93
|
WELL_KNOWN_TYPES,
|
|
84
94
|
} from "./resolve";
|
|
85
|
-
export type { SectionLoaderContext } from "./sectionLoaderContext";
|
|
86
|
-
export { buildSectionLoaderContext } from "./sectionLoaderContext";
|
|
87
95
|
export type {
|
|
88
96
|
ActionConfig,
|
|
89
97
|
AppSchemas,
|
|
@@ -105,6 +113,8 @@ export {
|
|
|
105
113
|
registerMatcherSchema,
|
|
106
114
|
registerMatcherSchemas,
|
|
107
115
|
} from "./schema";
|
|
116
|
+
export type { SectionLoaderContext } from "./sectionLoaderContext";
|
|
117
|
+
export { buildSectionLoaderContext } from "./sectionLoaderContext";
|
|
108
118
|
export type { SectionLoaderFn } from "./sectionLoaders";
|
|
109
119
|
export {
|
|
110
120
|
getDegradedSections,
|
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/schema.test.ts
CHANGED
|
@@ -253,10 +253,9 @@ describe("commerce SEO section schemas", () => {
|
|
|
253
253
|
// PLP-specific structured-data config (see seo-form-mode.ts PLP_SEO_FIELD_KEYS).
|
|
254
254
|
expect(props.configJsonLD.properties).toHaveProperty("removeVideos");
|
|
255
255
|
expect(props.configJsonLD.properties).toHaveProperty("ignoreStructuredData");
|
|
256
|
-
// The data source is
|
|
257
|
-
//
|
|
258
|
-
expect(
|
|
259
|
-
expect(props.jsonLD.title).toBe("Data Source");
|
|
256
|
+
// The data source is set once at creation and stays hidden in the SEO panel;
|
|
257
|
+
// it round-trips via the form rather than being re-picked here.
|
|
258
|
+
expect(props.jsonLD.hide).toBe(true);
|
|
260
259
|
});
|
|
261
260
|
|
|
262
261
|
it("emits editable props for the product-details SEO section (pdp mode)", () => {
|
|
@@ -269,8 +268,7 @@ describe("commerce SEO section schemas", () => {
|
|
|
269
268
|
// PDP-specific fields (see seo-form-mode.ts PDP_SEO_FIELD_KEYS).
|
|
270
269
|
expect(props).toHaveProperty("omitVariants");
|
|
271
270
|
expect(props).toHaveProperty("ignoreStructuredData");
|
|
272
|
-
expect(
|
|
273
|
-
expect(props.jsonLD.title).toBe("Data Source");
|
|
271
|
+
expect(props.jsonLD.hide).toBe(true);
|
|
274
272
|
});
|
|
275
273
|
|
|
276
274
|
it("registers the defs + manifest blocks and offers them as page.seo options", () => {
|
|
@@ -287,51 +285,6 @@ describe("commerce SEO section schemas", () => {
|
|
|
287
285
|
expect(meta.schema.root.sections.anyOf).toContainEqual(b64Ref(key));
|
|
288
286
|
}
|
|
289
287
|
});
|
|
290
|
-
|
|
291
|
-
// Mirrors a generated site meta: a loader carries a real def AND is listed in
|
|
292
|
-
// the site's loader union. `@ignore`d loaders are the exception — they get a
|
|
293
|
-
// def but are withheld from the union.
|
|
294
|
-
function siteMetaWithLoader(key: string, opts: { inUnion: boolean }) {
|
|
295
|
-
const site = emptySiteMeta();
|
|
296
|
-
site.schema.definitions[b64(key)] = {
|
|
297
|
-
title: key,
|
|
298
|
-
type: "object",
|
|
299
|
-
properties: { __resolveType: { type: "string", enum: [key] } },
|
|
300
|
-
};
|
|
301
|
-
site.manifest.blocks.loaders = {
|
|
302
|
-
[key]: { $ref: `#/definitions/${b64(key)}`, namespace: "site" },
|
|
303
|
-
};
|
|
304
|
-
site.schema.root = { loaders: { anyOf: opts.inUnion ? [b64Ref(key)] : [] } };
|
|
305
|
-
return site;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
it("feeds the site's own loaders into the jsonLD data-source picker", () => {
|
|
309
|
-
// The runtime loader registry is empty at generation time, so the union must
|
|
310
|
-
// be seeded from the site's own loader union — else the picker has no
|
|
311
|
-
// options and the user can't wire the data source.
|
|
312
|
-
const loaderKey = "site/loaders/product/productDetailsPage.ts";
|
|
313
|
-
const meta = composeMeta(siteMetaWithLoader(loaderKey, { inUnion: true }));
|
|
314
|
-
|
|
315
|
-
// The picker option is real, not a dangling $ref: its def survives.
|
|
316
|
-
expect(meta.schema.definitions).toHaveProperty(b64(loaderKey));
|
|
317
|
-
// …offered in both the root union and the commerce SEO jsonLD picker.
|
|
318
|
-
expect(meta.schema.root.loaders.anyOf).toContainEqual(b64Ref(loaderKey));
|
|
319
|
-
const seoDef =
|
|
320
|
-
meta.schema.definitions[b64("commerce/sections/Seo/SeoPDPV2.tsx")];
|
|
321
|
-
expect(seoDef.properties.jsonLD.anyOf).toContainEqual(b64Ref(loaderKey));
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it("keeps @ignore'd loaders (in the manifest, not the union) out of the picker", () => {
|
|
325
|
-
// Seeding from `manifest.blocks.loaders` would resurrect hidden loaders;
|
|
326
|
-
// seeding from the union keeps them out.
|
|
327
|
-
const hiddenKey = "site/loaders/internal/hidden.ts";
|
|
328
|
-
const meta = composeMeta(siteMetaWithLoader(hiddenKey, { inUnion: false }));
|
|
329
|
-
|
|
330
|
-
expect(meta.schema.root.loaders.anyOf).not.toContainEqual(b64Ref(hiddenKey));
|
|
331
|
-
const seoDef =
|
|
332
|
-
meta.schema.definitions[b64("commerce/sections/Seo/SeoPDPV2.tsx")];
|
|
333
|
-
expect(seoDef.properties.jsonLD.anyOf).not.toContainEqual(b64Ref(hiddenKey));
|
|
334
|
-
});
|
|
335
288
|
});
|
|
336
289
|
|
|
337
290
|
describe("composeMeta idempotency", () => {
|
package/src/cms/schema.ts
CHANGED
|
@@ -731,7 +731,7 @@ function buildPageSchema(sectionAnyOf: any[]) {
|
|
|
731
731
|
// Framework sections
|
|
732
732
|
// ---------------------------------------------------------------------------
|
|
733
733
|
|
|
734
|
-
function buildFrameworkSections(sectionAnyOf: any[]
|
|
734
|
+
function buildFrameworkSections(sectionAnyOf: any[]) {
|
|
735
735
|
const definitions: Record<string, any> = {};
|
|
736
736
|
const manifestBlocks: Record<string, any> = {};
|
|
737
737
|
const extraAnyOf: any[] = [];
|
|
@@ -839,12 +839,8 @@ function buildFrameworkSections(sectionAnyOf: any[], loaderUnion: any[]) {
|
|
|
839
839
|
// there is no manifest to emit these props from. The Studio's SEO editor
|
|
840
840
|
// already knows this type as its "plp" mode (see seo-form-mode.ts); it just
|
|
841
841
|
// needs the props schema to render the override fields. `jsonLD` is the
|
|
842
|
-
// data-source
|
|
843
|
-
//
|
|
844
|
-
// loader that feeds the structured data. Inlined (a fresh copy) rather than a
|
|
845
|
-
// `$ref: "#/root/loaders"` because the Studio drops the prop-level title when
|
|
846
|
-
// resolving a `$ref` union, which would lose the "Data Source" label; the
|
|
847
|
-
// copy also avoids sharing one array instance across the two defs + root.
|
|
842
|
+
// data-source block-ref set once when the page is created; it round-trips via
|
|
843
|
+
// the form (kept `hide`) rather than being re-picked in the SEO panel.
|
|
848
844
|
//
|
|
849
845
|
// Added to `extraAnyOf` (offered as a selectable page.seo type) unconditionally,
|
|
850
846
|
// like the website Seo sections above. Gating this on "is a commerce site" via
|
|
@@ -865,7 +861,12 @@ function buildFrameworkSections(sectionAnyOf: any[], loaderUnion: any[]) {
|
|
|
865
861
|
enum: [SEO_PLP_V2_TYPE],
|
|
866
862
|
default: SEO_PLP_V2_TYPE,
|
|
867
863
|
},
|
|
868
|
-
jsonLD: {
|
|
864
|
+
jsonLD: {
|
|
865
|
+
type: "object",
|
|
866
|
+
title: "Data Source",
|
|
867
|
+
additionalProperties: true,
|
|
868
|
+
hide: true,
|
|
869
|
+
},
|
|
869
870
|
title: { type: "string", title: "Title Override" },
|
|
870
871
|
description: { type: "string", title: "Description Override" },
|
|
871
872
|
noIndexing: { type: "boolean", title: "Disable indexing" },
|
|
@@ -899,7 +900,12 @@ function buildFrameworkSections(sectionAnyOf: any[], loaderUnion: any[]) {
|
|
|
899
900
|
enum: [SEO_PDP_V2_TYPE],
|
|
900
901
|
default: SEO_PDP_V2_TYPE,
|
|
901
902
|
},
|
|
902
|
-
jsonLD: {
|
|
903
|
+
jsonLD: {
|
|
904
|
+
type: "object",
|
|
905
|
+
title: "Data Source",
|
|
906
|
+
additionalProperties: true,
|
|
907
|
+
hide: true,
|
|
908
|
+
},
|
|
903
909
|
omitVariants: { type: "boolean", title: "Omit variants" },
|
|
904
910
|
title: { type: "string", title: "Title Override" },
|
|
905
911
|
description: { type: "string", title: "Description Override" },
|
|
@@ -1103,36 +1109,11 @@ export function composeMeta(
|
|
|
1103
1109
|
|
|
1104
1110
|
const siteAnyOf = siteMeta.schema?.root?.sections?.anyOf || [];
|
|
1105
1111
|
|
|
1106
|
-
// Build all framework components
|
|
1107
|
-
|
|
1108
|
-
// include the site's OWN loaders. The runtime registry (loaderRegistry) is
|
|
1109
|
-
// empty at generation time, so `buildLoaderDefinitions` alone yields just
|
|
1110
|
-
// `Resolvable` — merge in the site's own loaders so loader-backed pickers have
|
|
1111
|
-
// real options in the baked meta, not only at runtime.
|
|
1112
|
-
//
|
|
1113
|
-
// Seed from the site's loader UNION (`schema.root.loaders.anyOf`), NOT
|
|
1114
|
-
// `manifest.blocks.loaders`: the generator keeps `@ignore`d loaders in the
|
|
1115
|
-
// manifest (they still need a def) while withholding them from the union so
|
|
1116
|
-
// they can't be picked — reading the manifest would resurrect them. This
|
|
1117
|
-
// source is also already `{ $ref }`-shaped and only points at defs that exist
|
|
1118
|
-
// (no dangling refs).
|
|
1119
|
-
//
|
|
1120
|
-
// TODO: `root.matchers`/`root.actions` have the same empty-union-at-gen-time
|
|
1121
|
-
// gap; only loaders are merged here (commerce SEO scope). Generalize if a
|
|
1122
|
-
// matcher/action picker ever needs baked options.
|
|
1123
|
-
const loaders = buildLoaderDefinitions();
|
|
1124
|
-
const siteLoaderRefs = (siteMeta.schema?.root?.loaders?.anyOf ?? []).filter(
|
|
1125
|
-
(ref: any): ref is { $ref: string } => Boolean(ref) && typeof ref.$ref === "string",
|
|
1126
|
-
);
|
|
1127
|
-
const seenLoaderRefs = new Set<string>(loaders.loaderAnyOf.map((r: any) => r.$ref));
|
|
1128
|
-
const loaderUnion = [
|
|
1129
|
-
...loaders.loaderAnyOf,
|
|
1130
|
-
...siteLoaderRefs.filter((r) => !seenLoaderRefs.has(r.$ref)),
|
|
1131
|
-
];
|
|
1132
|
-
|
|
1133
|
-
const fwSections = buildFrameworkSections(siteAnyOf, loaderUnion);
|
|
1112
|
+
// Build all framework components
|
|
1113
|
+
const fwSections = buildFrameworkSections(siteAnyOf);
|
|
1134
1114
|
const fullSectionAnyOf = [...siteAnyOf, ...fwSections.extraAnyOf];
|
|
1135
1115
|
const page = buildPageSchema(fullSectionAnyOf);
|
|
1116
|
+
const loaders = buildLoaderDefinitions();
|
|
1136
1117
|
const actions = buildActionDefinitions();
|
|
1137
1118
|
const matchers = buildMatcherDefinitions();
|
|
1138
1119
|
|
|
@@ -1193,7 +1174,7 @@ export function composeMeta(
|
|
|
1193
1174
|
...(siteMeta.schema?.root || {}),
|
|
1194
1175
|
sections: { anyOf: fullSectionAnyOf },
|
|
1195
1176
|
pages: { anyOf: page.rootAnyOf },
|
|
1196
|
-
loaders: { anyOf:
|
|
1177
|
+
loaders: { anyOf: loaders.loaderAnyOf },
|
|
1197
1178
|
matchers: { anyOf: matchers.matcherAnyOf },
|
|
1198
1179
|
},
|
|
1199
1180
|
},
|