@decocms/start 0.25.5 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/admin/index.ts +1 -1
- package/src/admin/render.ts +108 -28
- package/src/admin/setup.ts +32 -0
- package/src/cms/index.ts +1 -0
- package/src/cms/resolve.ts +65 -0
package/package.json
CHANGED
package/src/admin/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ export {
|
|
|
9
9
|
} from "./invoke";
|
|
10
10
|
export { LIVE_CONTROLS_SCRIPT } from "./liveControls";
|
|
11
11
|
export { handleMeta, setMetaData } from "./meta";
|
|
12
|
-
export { handleRender, setRenderShell } from "./render";
|
|
12
|
+
export { handleRender, setPreviewWrapper, setRenderShell } from "./render";
|
|
13
13
|
export {
|
|
14
14
|
composeMeta,
|
|
15
15
|
getRegisteredLoaders,
|
package/src/admin/render.ts
CHANGED
|
@@ -1,19 +1,60 @@
|
|
|
1
1
|
import { createElement } from "react";
|
|
2
2
|
import { loadBlocks, withBlocksOverride } from "../cms/loader";
|
|
3
3
|
import { getSection } from "../cms/registry";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type MatcherContext,
|
|
6
|
+
type ResolvedSection,
|
|
7
|
+
resolvePageSections,
|
|
8
|
+
resolveValue,
|
|
9
|
+
WELL_KNOWN_TYPES,
|
|
10
|
+
} from "../cms/resolve";
|
|
11
|
+
import { runSingleSectionLoader } from "../cms/sectionLoaders";
|
|
5
12
|
import { buildHtmlShell } from "../sdk/htmlShell";
|
|
6
13
|
import { LIVE_CONTROLS_SCRIPT } from "./liveControls";
|
|
14
|
+
import { getPreviewWrapper } from "./setup";
|
|
7
15
|
|
|
8
|
-
export { setRenderShell } from "./setup";
|
|
16
|
+
export { setRenderShell, setPreviewWrapper } from "./setup";
|
|
17
|
+
|
|
18
|
+
// Cache the dynamic import — avoids re-importing per section render
|
|
19
|
+
let _renderToString: ((element: any) => string) | null = null;
|
|
20
|
+
async function getRenderToString() {
|
|
21
|
+
if (!_renderToString) {
|
|
22
|
+
const mod = await import("react-dom/server");
|
|
23
|
+
_renderToString = mod.renderToString;
|
|
24
|
+
}
|
|
25
|
+
return _renderToString;
|
|
26
|
+
}
|
|
9
27
|
|
|
10
28
|
function wrapInHtmlShell(sectionHtml: string): string {
|
|
11
29
|
return buildHtmlShell({ body: sectionHtml, script: LIVE_CONTROLS_SCRIPT });
|
|
12
30
|
}
|
|
13
31
|
|
|
14
32
|
/**
|
|
15
|
-
* Render a single
|
|
16
|
-
*
|
|
33
|
+
* Render a single ResolvedSection to an HTML string.
|
|
34
|
+
* Uses the pre-cached renderToString and the preview wrapper.
|
|
35
|
+
*/
|
|
36
|
+
async function renderResolvedSection(section: ResolvedSection): Promise<string> {
|
|
37
|
+
const sectionLoader = getSection(section.component);
|
|
38
|
+
if (!sectionLoader) {
|
|
39
|
+
return `<div style="padding:8px;color:orange;font-size:12px;border:1px dashed orange;margin:4px 0;">Unsupported: ${section.component}</div>`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const renderToString = await getRenderToString();
|
|
44
|
+
const mod = await sectionLoader();
|
|
45
|
+
const element = createElement(mod.default, section.props);
|
|
46
|
+
const Wrapper = getPreviewWrapper();
|
|
47
|
+
const wrapped = Wrapper ? createElement(Wrapper, null, element) : element;
|
|
48
|
+
return renderToString(wrapped);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return `<div style="padding:8px;color:red;font-size:12px;">Error rendering ${section.component}: ${(error as Error).message}</div>`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Render a single raw section object (with __resolveType) to HTML.
|
|
56
|
+
* Kept for the single-section preview path where we don't go through
|
|
57
|
+
* resolvePageSections.
|
|
17
58
|
*/
|
|
18
59
|
async function renderOneSection(section: Record<string, unknown>): Promise<string> {
|
|
19
60
|
const resolveType = section.__resolveType as string | undefined;
|
|
@@ -26,14 +67,48 @@ async function renderOneSection(section: Record<string, unknown>): Promise<strin
|
|
|
26
67
|
|
|
27
68
|
try {
|
|
28
69
|
const { __resolveType: _, ...sectionProps } = section;
|
|
29
|
-
const
|
|
70
|
+
const renderToString = await getRenderToString();
|
|
30
71
|
const mod = await sectionLoader();
|
|
31
|
-
|
|
72
|
+
const element = createElement(mod.default, sectionProps);
|
|
73
|
+
const Wrapper = getPreviewWrapper();
|
|
74
|
+
const wrapped = Wrapper ? createElement(Wrapper, null, element) : element;
|
|
75
|
+
return renderToString(wrapped);
|
|
32
76
|
} catch (error) {
|
|
33
77
|
return `<div style="padding:8px;color:red;font-size:12px;">Error rendering ${resolveType}: ${(error as Error).message}</div>`;
|
|
34
78
|
}
|
|
35
79
|
}
|
|
36
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Build a MatcherContext from the preview request.
|
|
83
|
+
* Enables matchers (device, date, cookie, etc.) to evaluate correctly
|
|
84
|
+
* during preview resolution.
|
|
85
|
+
*/
|
|
86
|
+
function buildPreviewMatcherCtx(request: Request): MatcherContext {
|
|
87
|
+
const url = new URL(request.url);
|
|
88
|
+
const deviceHint = url.searchParams.get("deviceHint");
|
|
89
|
+
const path = url.searchParams.get("path") || "/";
|
|
90
|
+
|
|
91
|
+
let userAgent = request.headers.get("user-agent") ?? "";
|
|
92
|
+
if (deviceHint === "mobile" && !/mobile/i.test(userAgent)) {
|
|
93
|
+
userAgent += " Mobile";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const cookieHeader = request.headers.get("cookie") ?? "";
|
|
97
|
+
const cookies: Record<string, string> = {};
|
|
98
|
+
for (const part of cookieHeader.split(";")) {
|
|
99
|
+
const [k, ...v] = part.trim().split("=");
|
|
100
|
+
if (k) cookies[k.trim()] = v.join("=").trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
userAgent,
|
|
105
|
+
url: url.toString(),
|
|
106
|
+
path,
|
|
107
|
+
cookies,
|
|
108
|
+
request,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
37
112
|
/**
|
|
38
113
|
* Handles /live/previews/* -- renders sections to HTML for the admin preview.
|
|
39
114
|
*
|
|
@@ -117,28 +192,30 @@ export async function handleRender(request: Request): Promise<Response> {
|
|
|
117
192
|
}
|
|
118
193
|
}
|
|
119
194
|
|
|
120
|
-
// Page compositor: resolve + render all child sections
|
|
195
|
+
// Page compositor: resolve + render all child sections in parallel
|
|
121
196
|
if (component === WELL_KNOWN_TYPES.PAGE) {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
197
|
+
const matcherCtx = buildPreviewMatcherCtx(request);
|
|
198
|
+
|
|
199
|
+
// resolvePageSections uses the same strategy as resolveDecoPage:
|
|
200
|
+
// parallel section resolution, layout caching, in-flight dedup, memoization
|
|
201
|
+
const resolvedSections = await resolvePageSections(
|
|
202
|
+
props.sections,
|
|
203
|
+
matcherCtx,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// Run section loaders in parallel — benefits from layout and cacheable caches
|
|
207
|
+
const enrichedSections = await Promise.all(
|
|
208
|
+
resolvedSections.map((section) =>
|
|
209
|
+
runSingleSectionLoader(section, request).catch(() => section),
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Render all sections in parallel
|
|
214
|
+
const htmlParts = await Promise.all(
|
|
215
|
+
enrichedSections.map((section) => renderResolvedSection(section)),
|
|
216
|
+
);
|
|
140
217
|
|
|
141
|
-
return new Response(wrapInHtmlShell(htmlParts.join("\n")), {
|
|
218
|
+
return new Response(wrapInHtmlShell(htmlParts.filter(Boolean).join("\n")), {
|
|
142
219
|
status: 200,
|
|
143
220
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
144
221
|
});
|
|
@@ -159,9 +236,12 @@ export async function handleRender(request: Request): Promise<Response> {
|
|
|
159
236
|
try {
|
|
160
237
|
const resolvedProps = (await resolveValue(props)) as Record<string, unknown>;
|
|
161
238
|
const { __resolveType: _, ...cleanProps } = resolvedProps;
|
|
162
|
-
const
|
|
239
|
+
const renderToString = await getRenderToString();
|
|
163
240
|
const mod = await sectionLoader();
|
|
164
|
-
const
|
|
241
|
+
const element = createElement(mod.default, cleanProps);
|
|
242
|
+
const Wrapper = getPreviewWrapper();
|
|
243
|
+
const wrapped = Wrapper ? createElement(Wrapper, null, element) : element;
|
|
244
|
+
const sectionHtml = renderToString(wrapped);
|
|
165
245
|
return new Response(wrapInHtmlShell(sectionHtml), {
|
|
166
246
|
status: 200,
|
|
167
247
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
package/src/admin/setup.ts
CHANGED
|
@@ -25,6 +25,38 @@ let themeName = "";
|
|
|
25
25
|
let bodyClass = "";
|
|
26
26
|
let htmlLang = "en";
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Optional React component that wraps section renders in admin previews.
|
|
30
|
+
* Use this to provide context that sections depend on (Router, QueryClient, etc.)
|
|
31
|
+
* without which renderToString would crash.
|
|
32
|
+
*
|
|
33
|
+
* The wrapper receives `{ children }` and should render them inside the
|
|
34
|
+
* necessary providers.
|
|
35
|
+
*/
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
let previewWrapperComponent: any = null;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Register a wrapper component for admin preview renders.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* // In site's setup.ts:
|
|
45
|
+
* import { setPreviewWrapper } from "@decocms/start/admin/setup";
|
|
46
|
+
* import { PreviewProviders } from "./components/PreviewProviders";
|
|
47
|
+
* setPreviewWrapper(PreviewProviders);
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
51
|
+
export function setPreviewWrapper(wrapper: any) {
|
|
52
|
+
previewWrapperComponent = wrapper;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
+
export function getPreviewWrapper(): any {
|
|
57
|
+
return previewWrapperComponent;
|
|
58
|
+
}
|
|
59
|
+
|
|
28
60
|
export function setRenderShell(opts: {
|
|
29
61
|
css?: string;
|
|
30
62
|
fonts?: string[];
|
package/src/cms/index.ts
CHANGED
package/src/cms/resolve.ts
CHANGED
|
@@ -1276,6 +1276,71 @@ export async function resolveDecoPage(
|
|
|
1276
1276
|
};
|
|
1277
1277
|
}
|
|
1278
1278
|
|
|
1279
|
+
/**
|
|
1280
|
+
* Resolve a raw sections array into ResolvedSection[] with the same
|
|
1281
|
+
* optimizations as resolveDecoPage: parallel resolution, layout caching,
|
|
1282
|
+
* in-flight dedup, and memoization.
|
|
1283
|
+
*
|
|
1284
|
+
* Unlike resolveDecoPage, this does NOT look up a page by path — the
|
|
1285
|
+
* caller provides the raw section array directly. Used by the admin
|
|
1286
|
+
* preview renderer where page data comes from the POST body.
|
|
1287
|
+
*
|
|
1288
|
+
* All sections are resolved eagerly (no deferred/async split) since
|
|
1289
|
+
* admin previews need the full rendered output.
|
|
1290
|
+
*/
|
|
1291
|
+
export async function resolvePageSections(
|
|
1292
|
+
rawSectionsInput: unknown,
|
|
1293
|
+
matcherCtx?: MatcherContext,
|
|
1294
|
+
): Promise<ResolvedSection[]> {
|
|
1295
|
+
ensureInitialized();
|
|
1296
|
+
|
|
1297
|
+
const ctx: MatcherContext = matcherCtx ?? {};
|
|
1298
|
+
const rctx: ResolveContext = { matcherCtx: ctx, memo: new Map(), depth: 0 };
|
|
1299
|
+
|
|
1300
|
+
let rawSections: unknown[];
|
|
1301
|
+
if (Array.isArray(rawSectionsInput)) {
|
|
1302
|
+
rawSections = rawSectionsInput;
|
|
1303
|
+
} else {
|
|
1304
|
+
rawSections = await resolveSectionsList(rawSectionsInput, rctx);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const eagerResults: Promise<ResolvedSection[]>[] = [];
|
|
1308
|
+
|
|
1309
|
+
for (const section of rawSections) {
|
|
1310
|
+
const promise = (async (): Promise<ResolvedSection[]> => {
|
|
1311
|
+
try {
|
|
1312
|
+
const layoutKey = isRawSectionLayout(section);
|
|
1313
|
+
|
|
1314
|
+
if (layoutKey) {
|
|
1315
|
+
const cached = getCachedResolvedLayout(layoutKey);
|
|
1316
|
+
if (cached) return cached;
|
|
1317
|
+
|
|
1318
|
+
const inflight = resolvedLayoutInflight.get(layoutKey);
|
|
1319
|
+
if (inflight) return inflight;
|
|
1320
|
+
|
|
1321
|
+
const p = resolveRawSection(section, rctx).then((results) => {
|
|
1322
|
+
setCachedResolvedLayout(layoutKey, results);
|
|
1323
|
+
return results;
|
|
1324
|
+
});
|
|
1325
|
+
resolvedLayoutInflight.set(layoutKey, p);
|
|
1326
|
+
p.finally(() => resolvedLayoutInflight.delete(layoutKey));
|
|
1327
|
+
return p;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
return resolveRawSection(section, rctx);
|
|
1331
|
+
} catch (e) {
|
|
1332
|
+
onResolveError(e, "section", "Preview section resolution");
|
|
1333
|
+
return [];
|
|
1334
|
+
}
|
|
1335
|
+
})();
|
|
1336
|
+
|
|
1337
|
+
eagerResults.push(promise);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const allResults = await Promise.all(eagerResults);
|
|
1341
|
+
return allResults.flat();
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1279
1344
|
/**
|
|
1280
1345
|
* Resolve a single deferred section's raw props into a fully resolved section.
|
|
1281
1346
|
* Called by the loadDeferredSection server function when a section scrolls into view.
|