@decocms/blocks-admin 7.16.5 → 7.16.7
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 +2 -2
- package/src/admin/index.ts +18 -14
- package/src/admin/render.ts +37 -222
- package/src/admin/resolvePreview.test.ts +129 -0
- package/src/admin/resolvePreview.ts +176 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-admin",
|
|
3
|
-
"version": "7.16.
|
|
3
|
+
"version": "7.16.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco admin protocol: /live/_meta, /.decofile, /deco/invoke, /deco/render, plus admin-facing site setup and commerce-app auto-configuration",
|
|
6
6
|
"repository": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"lint:unused": "knip"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@decocms/blocks": "7.16.
|
|
28
|
+
"@decocms/blocks": "7.16.7"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"react": "^19.0.0",
|
package/src/admin/index.ts
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type ActionConfig,
|
|
3
|
+
composeMeta,
|
|
4
|
+
getRegisteredLoaders,
|
|
5
|
+
getRegisteredMatchers,
|
|
6
|
+
type LoaderConfig,
|
|
7
|
+
type MatcherConfig,
|
|
8
|
+
type MetaResponse,
|
|
9
|
+
registerActionSchema,
|
|
10
|
+
registerActionSchemas,
|
|
11
|
+
registerLoaderSchema,
|
|
12
|
+
registerLoaderSchemas,
|
|
13
|
+
registerMatcherSchema,
|
|
14
|
+
registerMatcherSchemas,
|
|
15
|
+
} from "@decocms/blocks/cms";
|
|
1
16
|
export { corsHeaders, isAdminOrLocalhost, registerAdminOrigin, registerAdminOrigins } from "./cors";
|
|
2
17
|
export { handleDecofileRead, handleDecofileReload, setFastDeployKVGetter } from "./decofile";
|
|
3
18
|
export {
|
|
@@ -13,17 +28,6 @@ export { LIVE_CONTROLS_SCRIPT } from "./liveControls";
|
|
|
13
28
|
export { handleMeta, setMetaData } from "./meta";
|
|
14
29
|
export { handleRender, setPreviewWrapper, setRenderShell } from "./render";
|
|
15
30
|
export {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
type ActionConfig,
|
|
20
|
-
type LoaderConfig,
|
|
21
|
-
type MatcherConfig,
|
|
22
|
-
type MetaResponse,
|
|
23
|
-
registerActionSchema,
|
|
24
|
-
registerActionSchemas,
|
|
25
|
-
registerLoaderSchema,
|
|
26
|
-
registerLoaderSchemas,
|
|
27
|
-
registerMatcherSchema,
|
|
28
|
-
registerMatcherSchemas,
|
|
29
|
-
} from "@decocms/blocks/cms";
|
|
31
|
+
type PreviewResolution,
|
|
32
|
+
resolvePreviewRequest,
|
|
33
|
+
} from "./resolvePreview";
|
package/src/admin/render.ts
CHANGED
|
@@ -1,20 +1,11 @@
|
|
|
1
|
+
import { getSection, type ResolvedSection } from "@decocms/blocks/cms";
|
|
1
2
|
import { createElement } from "react";
|
|
2
|
-
import {
|
|
3
|
-
loadBlocks,
|
|
4
|
-
withBlocksOverride,
|
|
5
|
-
getSection,
|
|
6
|
-
type MatcherContext,
|
|
7
|
-
type ResolvedSection,
|
|
8
|
-
resolvePageSections,
|
|
9
|
-
resolveValue,
|
|
10
|
-
WELL_KNOWN_TYPES,
|
|
11
|
-
runSingleSectionLoader,
|
|
12
|
-
} from "@decocms/blocks/cms";
|
|
13
3
|
import { buildHtmlShell } from "../sdk/htmlShell";
|
|
14
4
|
import { LIVE_CONTROLS_SCRIPT } from "./liveControls";
|
|
5
|
+
import { resolvePreviewRequest } from "./resolvePreview";
|
|
15
6
|
import { getPreviewWrapper } from "./setup";
|
|
16
7
|
|
|
17
|
-
export {
|
|
8
|
+
export { setPreviewWrapper, setRenderShell } from "./setup";
|
|
18
9
|
|
|
19
10
|
/** Escape user-controlled strings before interpolating into HTML. */
|
|
20
11
|
function escapeHtml(str: string): string {
|
|
@@ -50,74 +41,12 @@ async function renderResolvedSection(section: ResolvedSection): Promise<string>
|
|
|
50
41
|
return `<div style="padding:8px;color:orange;font-size:12px;border:1px dashed orange;margin:4px 0;">Unsupported: ${escapeHtml(section.component)}</div>`;
|
|
51
42
|
}
|
|
52
43
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return renderToString(wrapped);
|
|
60
|
-
} catch (error) {
|
|
61
|
-
return `<div style="padding:8px;color:red;font-size:12px;">Error rendering ${escapeHtml(section.component)}: ${escapeHtml((error as Error).message)}</div>`;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Render a single raw section object (with __resolveType) to HTML.
|
|
67
|
-
* Kept for the single-section preview path where we don't go through
|
|
68
|
-
* resolvePageSections.
|
|
69
|
-
*/
|
|
70
|
-
async function renderOneSection(section: Record<string, unknown>): Promise<string> {
|
|
71
|
-
const resolveType = section.__resolveType as string | undefined;
|
|
72
|
-
if (!resolveType) return "";
|
|
73
|
-
|
|
74
|
-
const sectionLoader = getSection(resolveType);
|
|
75
|
-
if (!sectionLoader) {
|
|
76
|
-
return `<div style="padding:8px;color:orange;font-size:12px;border:1px dashed orange;margin:4px 0;">Unsupported: ${escapeHtml(resolveType)}</div>`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
const { __resolveType: _, ...sectionProps } = section;
|
|
81
|
-
const renderToString = await getRenderToString();
|
|
82
|
-
const mod = await sectionLoader();
|
|
83
|
-
const element = createElement(mod.default, sectionProps);
|
|
84
|
-
const Wrapper = getPreviewWrapper();
|
|
85
|
-
const wrapped = Wrapper ? createElement(Wrapper, null, element) : element;
|
|
86
|
-
return renderToString(wrapped);
|
|
87
|
-
} catch (error) {
|
|
88
|
-
return `<div style="padding:8px;color:red;font-size:12px;">Error rendering ${escapeHtml(resolveType)}: ${escapeHtml((error as Error).message)}</div>`;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Build a MatcherContext from the preview request.
|
|
94
|
-
* Enables matchers (device, date, cookie, etc.) to evaluate correctly
|
|
95
|
-
* during preview resolution.
|
|
96
|
-
*/
|
|
97
|
-
function buildPreviewMatcherCtx(request: Request): MatcherContext {
|
|
98
|
-
const url = new URL(request.url);
|
|
99
|
-
const deviceHint = url.searchParams.get("deviceHint");
|
|
100
|
-
const path = url.searchParams.get("path") || "/";
|
|
101
|
-
|
|
102
|
-
let userAgent = request.headers.get("user-agent") ?? "";
|
|
103
|
-
if (deviceHint === "mobile" && !/mobile/i.test(userAgent)) {
|
|
104
|
-
userAgent += " Mobile";
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const cookieHeader = request.headers.get("cookie") ?? "";
|
|
108
|
-
const cookies: Record<string, string> = {};
|
|
109
|
-
for (const part of cookieHeader.split(";")) {
|
|
110
|
-
const [k, ...v] = part.trim().split("=");
|
|
111
|
-
if (k) cookies[k.trim()] = v.join("=").trim();
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
userAgent,
|
|
116
|
-
url: url.toString(),
|
|
117
|
-
path,
|
|
118
|
-
cookies,
|
|
119
|
-
request,
|
|
120
|
-
};
|
|
44
|
+
const renderToString = await getRenderToString();
|
|
45
|
+
const mod = await sectionLoader();
|
|
46
|
+
const element = createElement(mod.default, section.props);
|
|
47
|
+
const Wrapper = getPreviewWrapper();
|
|
48
|
+
const wrapped = Wrapper ? createElement(Wrapper, null, element) : element;
|
|
49
|
+
return renderToString(wrapped);
|
|
121
50
|
}
|
|
122
51
|
|
|
123
52
|
/**
|
|
@@ -129,127 +58,11 @@ function buildPreviewMatcherCtx(request: Request): MatcherContext {
|
|
|
129
58
|
* - Per-request decofile override via AsyncLocalStorage
|
|
130
59
|
*/
|
|
131
60
|
export async function handleRender(request: Request): Promise<Response> {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const pathPrefix = "/live/previews/";
|
|
137
|
-
const rawPathComponent = url.pathname.startsWith(pathPrefix)
|
|
138
|
-
? url.pathname.slice(pathPrefix.length)
|
|
139
|
-
: "";
|
|
140
|
-
// Admin sends double-encoded path segments (e.g. space → %20 → %2520).
|
|
141
|
-
// Decode until stable so the block key matches the decofile.
|
|
142
|
-
const pathComponent = (() => {
|
|
143
|
-
try {
|
|
144
|
-
const once = decodeURIComponent(rawPathComponent);
|
|
145
|
-
try { return decodeURIComponent(once); } catch { return once; }
|
|
146
|
-
} catch { return rawPathComponent; }
|
|
147
|
-
})();
|
|
148
|
-
let component = resolveChain || pathComponent || "";
|
|
149
|
-
let props: Record<string, unknown> = {};
|
|
150
|
-
let decofileOverride: Record<string, unknown> | null = null;
|
|
151
|
-
|
|
152
|
-
if (request.method === "POST") {
|
|
153
|
-
try {
|
|
154
|
-
const body = await request.json();
|
|
155
|
-
if (body && typeof body === "object") {
|
|
156
|
-
if (body.__decofile && typeof body.__decofile === "object") {
|
|
157
|
-
decofileOverride = body.__decofile;
|
|
158
|
-
}
|
|
159
|
-
if (body.__props && typeof body.__props === "object") {
|
|
160
|
-
props = body.__props;
|
|
161
|
-
if (body.__props.__resolveType) {
|
|
162
|
-
component = body.__props.__resolveType as string;
|
|
163
|
-
}
|
|
164
|
-
} else if (body.props && typeof body.props === "object") {
|
|
165
|
-
props = body.props;
|
|
166
|
-
} else if (body.__resolveType) {
|
|
167
|
-
component = body.__resolveType as string;
|
|
168
|
-
const { __decofile: _, __resolveType: __, ...rest } = body;
|
|
169
|
-
props = rest;
|
|
170
|
-
} else if (!body.__decofile) {
|
|
171
|
-
props = body;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
} catch {
|
|
175
|
-
// fall through to query-param handling
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (!decofileOverride) {
|
|
180
|
-
const decofileParam = url.searchParams.get("__decofile");
|
|
181
|
-
if (decofileParam) {
|
|
182
|
-
try {
|
|
183
|
-
decofileOverride = JSON.parse(decodeURIComponent(decofileParam));
|
|
184
|
-
} catch {
|
|
185
|
-
// invalid __decofile param, ignore
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (propsParam && Object.keys(props).length === 0) {
|
|
191
|
-
try {
|
|
192
|
-
props = JSON.parse(decodeURIComponent(propsParam));
|
|
193
|
-
} catch {
|
|
194
|
-
// Try base64-encoded props (admin uses btoa(encodeURIComponent(json)))
|
|
195
|
-
try {
|
|
196
|
-
props = JSON.parse(decodeURIComponent(atob(propsParam)));
|
|
197
|
-
} catch {
|
|
198
|
-
// props parsing failed
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (props.__resolveType && !component) {
|
|
204
|
-
component = props.__resolveType as string;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const renderFn = async () => {
|
|
208
|
-
const blocks = loadBlocks();
|
|
209
|
-
|
|
210
|
-
// Resolve named block at the component level
|
|
211
|
-
if (blocks[component]) {
|
|
212
|
-
const block = blocks[component] as Record<string, unknown>;
|
|
213
|
-
if (block.__resolveType) {
|
|
214
|
-
component = block.__resolveType as string;
|
|
215
|
-
props = { ...block, ...props };
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Page compositor: resolve + render all child sections in parallel
|
|
220
|
-
if (component === WELL_KNOWN_TYPES.PAGE) {
|
|
221
|
-
const matcherCtx = buildPreviewMatcherCtx(request);
|
|
222
|
-
|
|
223
|
-
// resolvePageSections uses the same strategy as resolveDecoPage:
|
|
224
|
-
// parallel section resolution, layout caching, in-flight dedup, memoization
|
|
225
|
-
const resolvedSections = await resolvePageSections(
|
|
226
|
-
props.sections,
|
|
227
|
-
matcherCtx,
|
|
228
|
-
);
|
|
229
|
-
|
|
230
|
-
// Run section loaders in parallel — benefits from layout and cacheable caches
|
|
231
|
-
const enrichedSections = await Promise.all(
|
|
232
|
-
resolvedSections.map((section) =>
|
|
233
|
-
runSingleSectionLoader(section, request).catch(() => section),
|
|
234
|
-
),
|
|
235
|
-
);
|
|
236
|
-
|
|
237
|
-
// Render all sections in parallel
|
|
238
|
-
const htmlParts = await Promise.all(
|
|
239
|
-
enrichedSections.map((section) => renderResolvedSection(section)),
|
|
240
|
-
);
|
|
241
|
-
|
|
242
|
-
return new Response(wrapInHtmlShell(htmlParts.filter(Boolean).join("\n")), {
|
|
243
|
-
status: 200,
|
|
244
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Single section render
|
|
249
|
-
const sectionLoader = getSection(component);
|
|
250
|
-
if (!sectionLoader) {
|
|
61
|
+
try {
|
|
62
|
+
const resolution = await resolvePreviewRequest(request);
|
|
63
|
+
if (resolution.type === "unknown") {
|
|
251
64
|
const unknownHtml = wrapInHtmlShell(
|
|
252
|
-
`<div style="padding:20px;color:red;">Unknown section: ${escapeHtml(component)}</div>`,
|
|
65
|
+
`<div style="padding:20px;color:red;">Unknown section: ${escapeHtml(resolution.component)}</div>`,
|
|
253
66
|
);
|
|
254
67
|
return new Response(unknownHtml, {
|
|
255
68
|
status: 200,
|
|
@@ -257,32 +70,34 @@ export async function handleRender(request: Request): Promise<Response> {
|
|
|
257
70
|
});
|
|
258
71
|
}
|
|
259
72
|
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return new Response(wrapInHtmlShell(sectionHtml), {
|
|
270
|
-
status: 200,
|
|
271
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
272
|
-
});
|
|
273
|
-
} catch (error) {
|
|
274
|
-
const errorHtml = wrapInHtmlShell(
|
|
275
|
-
`<div style="padding:20px;color:red;">Render error: ${escapeHtml((error as Error).message)}</div>`,
|
|
73
|
+
if (resolution.previewType === "page") {
|
|
74
|
+
const htmlParts = await Promise.all(
|
|
75
|
+
resolution.sections.map(async (section) => {
|
|
76
|
+
try {
|
|
77
|
+
return await renderResolvedSection(section);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return `<div style="padding:8px;color:red;font-size:12px;">Error rendering ${escapeHtml(section.component)}: ${escapeHtml((error as Error).message)}</div>`;
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
276
82
|
);
|
|
277
|
-
return new Response(
|
|
83
|
+
return new Response(wrapInHtmlShell(htmlParts.filter(Boolean).join("\n")), {
|
|
278
84
|
status: 200,
|
|
279
|
-
headers: { "Content-Type": "text/html" },
|
|
85
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
280
86
|
});
|
|
281
87
|
}
|
|
282
|
-
};
|
|
283
88
|
|
|
284
|
-
|
|
285
|
-
return
|
|
89
|
+
const sectionHtml = await renderResolvedSection(resolution.sections[0]);
|
|
90
|
+
return new Response(wrapInHtmlShell(sectionHtml), {
|
|
91
|
+
status: 200,
|
|
92
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const errorHtml = wrapInHtmlShell(
|
|
96
|
+
`<div style="padding:20px;color:red;">Render error: ${escapeHtml((error as Error).message)}</div>`,
|
|
97
|
+
);
|
|
98
|
+
return new Response(errorHtml, {
|
|
99
|
+
status: 200,
|
|
100
|
+
headers: { "Content-Type": "text/html" },
|
|
101
|
+
});
|
|
286
102
|
}
|
|
287
|
-
return renderFn();
|
|
288
103
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import { registerSection, setBlocks, WELL_KNOWN_TYPES } from "@decocms/blocks/cms";
|
|
4
|
+
import { createElement } from "react";
|
|
5
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import * as admin from "./index";
|
|
7
|
+
import { handleRender } from "./render";
|
|
8
|
+
import { resolvePreviewRequest } from "./resolvePreview";
|
|
9
|
+
|
|
10
|
+
const HERO = "site/sections/Hero.tsx";
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
setBlocks({});
|
|
14
|
+
registerSection(HERO, async () => ({
|
|
15
|
+
default: ({ label }: { label?: string }) => createElement("h1", null, `hero-${label}`),
|
|
16
|
+
}));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("resolvePreviewRequest", () => {
|
|
20
|
+
it("is exposed by the blocks-admin protocol package", () => {
|
|
21
|
+
expect((admin as Record<string, unknown>).resolvePreviewRequest).toBeTypeOf("function");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("resolves a direct section and query-string props", async () => {
|
|
25
|
+
const props = encodeURIComponent(JSON.stringify({ label: "query" }));
|
|
26
|
+
const result = await resolvePreviewRequest(
|
|
27
|
+
new Request(`http://localhost/live/previews/${encodeURIComponent(HERO)}?props=${props}`),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(result).toEqual({
|
|
31
|
+
type: "sections",
|
|
32
|
+
previewType: "section",
|
|
33
|
+
component: HERO,
|
|
34
|
+
sections: [{ component: HERO, key: HERO, props: { label: "query" } }],
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("resolves a direct section and props from a POST body", async () => {
|
|
39
|
+
const result = await resolvePreviewRequest(
|
|
40
|
+
new Request("http://localhost/live/previews/ignored", {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
body: JSON.stringify({ __resolveType: HERO, label: "post" }),
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(result).toMatchObject({
|
|
48
|
+
type: "sections",
|
|
49
|
+
previewType: "section",
|
|
50
|
+
component: HERO,
|
|
51
|
+
sections: [{ component: HERO, props: { label: "post" } }],
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("resolves a double-encoded named block", async () => {
|
|
56
|
+
setBlocks({
|
|
57
|
+
"Hero Block": { __resolveType: HERO, label: "named" },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const result = await resolvePreviewRequest(
|
|
61
|
+
new Request("http://localhost/live/previews/Hero%2520Block"),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(result).toMatchObject({
|
|
65
|
+
type: "sections",
|
|
66
|
+
component: HERO,
|
|
67
|
+
sections: [{ component: HERO, props: { label: "named" } }],
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("resolves and enriches every section in a page preview", async () => {
|
|
72
|
+
const props = encodeURIComponent(
|
|
73
|
+
JSON.stringify({
|
|
74
|
+
sections: [{ __resolveType: HERO, label: "page" }],
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
const result = await resolvePreviewRequest(
|
|
78
|
+
new Request(
|
|
79
|
+
`http://localhost/live/previews/page?resolveChain=${encodeURIComponent(WELL_KNOWN_TYPES.PAGE)}&props=${props}`,
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(result).toMatchObject({
|
|
84
|
+
type: "sections",
|
|
85
|
+
component: WELL_KNOWN_TYPES.PAGE,
|
|
86
|
+
sections: [{ component: HERO, props: { label: "page" } }],
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("applies POST decofile overrides only for the current preview", async () => {
|
|
91
|
+
setBlocks({
|
|
92
|
+
"Hero Block": { __resolveType: HERO, label: "base" },
|
|
93
|
+
});
|
|
94
|
+
const request = new Request("http://localhost/live/previews/Hero%2520Block", {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "Content-Type": "application/json" },
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
__decofile: {
|
|
99
|
+
"Hero Block": { __resolveType: HERO, label: "override" },
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const result = await resolvePreviewRequest(request);
|
|
105
|
+
|
|
106
|
+
expect(result).toMatchObject({
|
|
107
|
+
type: "sections",
|
|
108
|
+
component: HERO,
|
|
109
|
+
sections: [{ component: HERO, props: { label: "override" } }],
|
|
110
|
+
});
|
|
111
|
+
expect(
|
|
112
|
+
await resolvePreviewRequest(new Request("http://localhost/live/previews/Hero%2520Block")),
|
|
113
|
+
).toMatchObject({ sections: [{ props: { label: "base" } }] });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("keeps handleRender's complete HTML response contract", async () => {
|
|
117
|
+
const props = encodeURIComponent(JSON.stringify({ label: "html" }));
|
|
118
|
+
const response = await handleRender(
|
|
119
|
+
new Request(`http://localhost/live/previews/${encodeURIComponent(HERO)}?props=${props}`),
|
|
120
|
+
);
|
|
121
|
+
const html = await response.text();
|
|
122
|
+
|
|
123
|
+
expect(response.status).toBe(200);
|
|
124
|
+
expect(response.headers.get("content-type")).toContain("text/html");
|
|
125
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
126
|
+
expect(html).toContain("hero-html");
|
|
127
|
+
expect(html).toContain("editor::inject");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getSection,
|
|
3
|
+
loadBlocks,
|
|
4
|
+
type MatcherContext,
|
|
5
|
+
type ResolvedSection,
|
|
6
|
+
resolvePageSections,
|
|
7
|
+
resolveValue,
|
|
8
|
+
runSingleSectionLoader,
|
|
9
|
+
WELL_KNOWN_TYPES,
|
|
10
|
+
withBlocksOverride,
|
|
11
|
+
} from "@decocms/blocks/cms";
|
|
12
|
+
|
|
13
|
+
export type PreviewResolution =
|
|
14
|
+
| {
|
|
15
|
+
type: "sections";
|
|
16
|
+
previewType: "page" | "section";
|
|
17
|
+
component: string;
|
|
18
|
+
sections: ResolvedSection[];
|
|
19
|
+
}
|
|
20
|
+
| { type: "unknown"; component: string };
|
|
21
|
+
|
|
22
|
+
type PreviewInput = {
|
|
23
|
+
component: string;
|
|
24
|
+
props: Record<string, unknown>;
|
|
25
|
+
decofileOverride: Record<string, unknown> | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function decodePreviewPath(value: string): string {
|
|
29
|
+
try {
|
|
30
|
+
const once = decodeURIComponent(value);
|
|
31
|
+
try {
|
|
32
|
+
return decodeURIComponent(once);
|
|
33
|
+
} catch {
|
|
34
|
+
return once;
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseJsonRecord(value: string): Record<string, unknown> | null {
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(value);
|
|
44
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
45
|
+
? (parsed as Record<string, unknown>)
|
|
46
|
+
: null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function parsePreviewInput(request: Request): Promise<PreviewInput> {
|
|
53
|
+
const url = new URL(request.url);
|
|
54
|
+
const resolveChain = url.searchParams.get("resolveChain");
|
|
55
|
+
const propsParam = url.searchParams.get("props");
|
|
56
|
+
const pathPrefix = "/live/previews/";
|
|
57
|
+
const rawPathComponent = url.pathname.startsWith(pathPrefix)
|
|
58
|
+
? url.pathname.slice(pathPrefix.length)
|
|
59
|
+
: "";
|
|
60
|
+
|
|
61
|
+
let component = resolveChain || decodePreviewPath(rawPathComponent) || "";
|
|
62
|
+
let props: Record<string, unknown> = {};
|
|
63
|
+
let decofileOverride: Record<string, unknown> | null = null;
|
|
64
|
+
|
|
65
|
+
if (request.method === "POST") {
|
|
66
|
+
try {
|
|
67
|
+
const body = (await request.json()) as Record<string, unknown> | null;
|
|
68
|
+
if (body && typeof body === "object") {
|
|
69
|
+
if (body.__decofile && typeof body.__decofile === "object") {
|
|
70
|
+
decofileOverride = body.__decofile as Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
if (body.__props && typeof body.__props === "object") {
|
|
73
|
+
props = body.__props as Record<string, unknown>;
|
|
74
|
+
if (typeof props.__resolveType === "string") {
|
|
75
|
+
component = props.__resolveType;
|
|
76
|
+
}
|
|
77
|
+
} else if (body.props && typeof body.props === "object") {
|
|
78
|
+
props = body.props as Record<string, unknown>;
|
|
79
|
+
} else if (typeof body.__resolveType === "string") {
|
|
80
|
+
component = body.__resolveType;
|
|
81
|
+
const { __decofile: _, __resolveType: __, ...rest } = body;
|
|
82
|
+
props = rest;
|
|
83
|
+
} else if (!body.__decofile) {
|
|
84
|
+
props = body;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Fall through to query-param parsing.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!decofileOverride) {
|
|
93
|
+
const decofileParam = url.searchParams.get("__decofile");
|
|
94
|
+
if (decofileParam) {
|
|
95
|
+
decofileOverride = parseJsonRecord(decodeURIComponent(decofileParam));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (propsParam && Object.keys(props).length === 0) {
|
|
100
|
+
props = parseJsonRecord(decodeURIComponent(propsParam)) ?? {};
|
|
101
|
+
if (Object.keys(props).length === 0) {
|
|
102
|
+
try {
|
|
103
|
+
props = parseJsonRecord(decodeURIComponent(atob(propsParam))) ?? {};
|
|
104
|
+
} catch {
|
|
105
|
+
// Invalid props remain an empty object.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof props.__resolveType === "string" && !component) {
|
|
111
|
+
component = props.__resolveType;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { component, props, decofileOverride };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildPreviewMatcherCtx(request: Request): MatcherContext {
|
|
118
|
+
const url = new URL(request.url);
|
|
119
|
+
const deviceHint = url.searchParams.get("deviceHint");
|
|
120
|
+
const path = url.searchParams.get("path") || "/";
|
|
121
|
+
|
|
122
|
+
let userAgent = request.headers.get("user-agent") ?? "";
|
|
123
|
+
if (deviceHint === "mobile" && !/mobile/i.test(userAgent)) {
|
|
124
|
+
userAgent += " Mobile";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const cookies: Record<string, string> = {};
|
|
128
|
+
for (const part of (request.headers.get("cookie") ?? "").split(";")) {
|
|
129
|
+
const [key, ...value] = part.trim().split("=");
|
|
130
|
+
if (key) cookies[key] = value.join("=").trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
userAgent,
|
|
135
|
+
url: url.toString(),
|
|
136
|
+
path,
|
|
137
|
+
cookies,
|
|
138
|
+
request,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function resolvePreviewRequest(request: Request): Promise<PreviewResolution> {
|
|
143
|
+
const input = await parsePreviewInput(request);
|
|
144
|
+
|
|
145
|
+
const resolve = async (): Promise<PreviewResolution> => {
|
|
146
|
+
let { component, props } = input;
|
|
147
|
+
const block = loadBlocks()[component] as Record<string, unknown> | undefined;
|
|
148
|
+
if (block && typeof block.__resolveType === "string") {
|
|
149
|
+
component = block.__resolveType;
|
|
150
|
+
props = { ...block, ...props };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (component === WELL_KNOWN_TYPES.PAGE) {
|
|
154
|
+
const resolved = await resolvePageSections(props.sections, buildPreviewMatcherCtx(request));
|
|
155
|
+
const sections = await Promise.all(
|
|
156
|
+
resolved.map((section) => runSingleSectionLoader(section, request).catch(() => section)),
|
|
157
|
+
);
|
|
158
|
+
return { type: "sections", previewType: "page", component, sections };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!getSection(component)) {
|
|
162
|
+
return { type: "unknown", component };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const resolvedProps = (await resolveValue(props)) as Record<string, unknown>;
|
|
166
|
+
const { __resolveType: _, ...cleanProps } = resolvedProps;
|
|
167
|
+
return {
|
|
168
|
+
type: "sections",
|
|
169
|
+
previewType: "section",
|
|
170
|
+
component,
|
|
171
|
+
sections: [{ component, key: component, props: cleanProps }],
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return input.decofileOverride ? withBlocksOverride(input.decofileOverride, resolve) : resolve();
|
|
176
|
+
}
|