@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.25.5",
3
+ "version": "0.26.1",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -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,
@@ -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 { resolveValue, WELL_KNOWN_TYPES } from "../cms/resolve";
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 resolved section object to an HTML string.
16
- * Returns empty string for unknown or SEO-only sections.
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 { renderToString } = await import("react-dom/server");
70
+ const renderToString = await getRenderToString();
30
71
  const mod = await sectionLoader();
31
- return renderToString(createElement(mod.default, sectionProps));
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 rawSections = props.sections;
123
- const resolvedSections = await resolveValue(rawSections);
124
- const sectionsList = Array.isArray(resolvedSections)
125
- ? resolvedSections
126
- : resolvedSections
127
- ? [resolvedSections]
128
- : [];
129
-
130
- const htmlParts: string[] = [];
131
- for (const section of sectionsList) {
132
- if (!section || typeof section !== "object" || Array.isArray(section)) {
133
- continue;
134
- }
135
- const sectionObj = section as Record<string, unknown>;
136
- if (!sectionObj.__resolveType) continue;
137
- const html = await renderOneSection(sectionObj);
138
- if (html) htmlParts.push(html);
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 { renderToString } = await import("react-dom/server");
239
+ const renderToString = await getRenderToString();
163
240
  const mod = await sectionLoader();
164
- const sectionHtml = renderToString(createElement(mod.default, cleanProps));
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" },
@@ -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
@@ -49,6 +49,7 @@ export {
49
49
  registerMatcher,
50
50
  registerSeoSections,
51
51
  resolveDecoPage,
52
+ resolvePageSections,
52
53
  resolvePageSeoBlock,
53
54
  resolveDeferredSection,
54
55
  resolveValue,
@@ -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.