@decocms/start 0.29.2 → 0.30.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.29.2",
3
+ "version": "0.30.0",
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",
@@ -19,7 +19,7 @@ vi.mock("./registry", () => ({
19
19
  getSection: vi.fn(),
20
20
  }));
21
21
 
22
- import { resolveDeferredSectionFull } from "./resolve";
22
+ import { resolveDeferredSectionFull, resolveSectionsList } from "./resolve";
23
23
  import { runSingleSectionLoader } from "./sectionLoaders";
24
24
  import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
25
25
  import type { DeferredSection } from "./resolve";
@@ -65,3 +65,104 @@ describe("resolveDeferredSectionFull", () => {
65
65
  expect(result).toEqual(input); // mock passes through
66
66
  });
67
67
  });
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // resolveSectionsList — page-level variant wrapper support
71
+ // ---------------------------------------------------------------------------
72
+
73
+ describe("resolveSectionsList", () => {
74
+ const makeRctx = (matcherCtx = {}) => ({
75
+ routeParams: {},
76
+ matcherCtx,
77
+ memo: new Map(),
78
+ depth: 0,
79
+ });
80
+
81
+ it("returns array as-is when value is already an array", async () => {
82
+ const sections = [{ __resolveType: "section-A" }, { __resolveType: "section-B" }];
83
+ const result = await resolveSectionsList(sections, makeRctx());
84
+ expect(result).toEqual(sections);
85
+ });
86
+
87
+ it("returns empty array for null/undefined/non-object", async () => {
88
+ expect(await resolveSectionsList(null, makeRctx())).toEqual([]);
89
+ expect(await resolveSectionsList(undefined, makeRctx())).toEqual([]);
90
+ expect(await resolveSectionsList("string", makeRctx())).toEqual([]);
91
+ expect(await resolveSectionsList(42, makeRctx())).toEqual([]);
92
+ });
93
+
94
+ it("resolves page-level variant wrapper without __resolveType", async () => {
95
+ // Simulates CMS admin wrapping all sections in a device variant
96
+ // Rule has no __resolveType → evaluateMatcher returns true (match-all)
97
+ const sectionsArray = [
98
+ { __resolveType: "Header - 01" },
99
+ { __resolveType: "site/sections/Account/PersonalData.tsx" },
100
+ { __resolveType: "Footer - 01" },
101
+ ];
102
+
103
+ const variantWrapper = {
104
+ variants: [
105
+ {
106
+ rule: { mobile: true, tablet: true, desktop: true },
107
+ value: sectionsArray,
108
+ },
109
+ ],
110
+ };
111
+
112
+ const result = await resolveSectionsList(variantWrapper, makeRctx());
113
+ expect(result).toEqual(sectionsArray);
114
+ });
115
+
116
+ it("returns empty when no variant matches in page-level wrapper", async () => {
117
+ // All variants have __resolveType in rule → evaluateMatcher returns false
118
+ // (unregistered matcher defaults to false)
119
+ const variantWrapper = {
120
+ variants: [
121
+ {
122
+ rule: { __resolveType: "website/matchers/device.ts", mobile: true },
123
+ value: [{ __resolveType: "MobileOnly" }],
124
+ },
125
+ ],
126
+ };
127
+
128
+ const result = await resolveSectionsList(variantWrapper, makeRctx());
129
+ expect(result).toEqual([]);
130
+ });
131
+
132
+ it("picks first matching variant in page-level wrapper", async () => {
133
+ const desktopSections = [{ __resolveType: "DesktopLayout" }];
134
+ const mobileSections = [{ __resolveType: "MobileLayout" }];
135
+
136
+ const variantWrapper = {
137
+ variants: [
138
+ {
139
+ // No __resolveType → evaluateMatcher returns true (first match wins)
140
+ rule: { desktop: true },
141
+ value: desktopSections,
142
+ },
143
+ {
144
+ rule: { mobile: true },
145
+ value: mobileSections,
146
+ },
147
+ ],
148
+ };
149
+
150
+ const result = await resolveSectionsList(variantWrapper, makeRctx());
151
+ expect(result).toEqual(desktopSections);
152
+ });
153
+
154
+ it("returns empty for object without __resolveType and without variants", async () => {
155
+ const result = await resolveSectionsList({ someKey: "value" }, makeRctx());
156
+ expect(result).toEqual([]);
157
+ });
158
+
159
+ it("respects max depth limit (20)", async () => {
160
+ // Build 21 levels of nested variant wrappers to exceed MAX_RESOLVE_DEPTH=20
161
+ let wrapper: any = [{ __resolveType: "deep" }];
162
+ for (let i = 0; i < 21; i++) {
163
+ wrapper = { variants: [{ rule: {}, value: wrapper }] };
164
+ }
165
+ const result = await resolveSectionsList(wrapper, makeRctx());
166
+ expect(result).toEqual([]);
167
+ });
168
+ });
@@ -1015,7 +1015,7 @@ function resolveSectionShallow(
1015
1015
  * to get the raw section array without resolving each individual section.
1016
1016
  * This allows the eager/deferred split to happen before section resolution.
1017
1017
  */
1018
- async function resolveSectionsList(
1018
+ export async function resolveSectionsList(
1019
1019
  value: unknown,
1020
1020
  rctx: ResolveContext,
1021
1021
  depth = 0,
@@ -1026,6 +1026,20 @@ async function resolveSectionsList(
1026
1026
 
1027
1027
  const obj = value as Record<string, unknown>;
1028
1028
  const rt = obj.__resolveType as string | undefined;
1029
+
1030
+ // Page-level variant wrapper without __resolveType
1031
+ // (CMS admin wraps ALL sections in a variant object with { variants: [...] })
1032
+ if (!rt && Array.isArray(obj.variants)) {
1033
+ const variants = obj.variants as Array<{ value: unknown; rule?: unknown }>;
1034
+ for (const variant of variants) {
1035
+ const rule = variant.rule as Record<string, unknown> | undefined;
1036
+ if (evaluateMatcher(rule, rctx.matcherCtx)) {
1037
+ return resolveSectionsList(variant.value, rctx, depth + 1);
1038
+ }
1039
+ }
1040
+ return [];
1041
+ }
1042
+
1029
1043
  if (!rt) return [];
1030
1044
 
1031
1045
  // Multivariate flags — evaluate matchers and recurse into matched variant
@@ -228,6 +228,33 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
228
228
  return normalizeUrlsInObject(enriched);
229
229
  });
230
230
 
231
+ // ---------------------------------------------------------------------------
232
+ // Pre-wrapped deferred section loader for IntersectionObserver-based rendering
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /**
236
+ * Convenience wrapper around `loadDeferredSection` that matches the
237
+ * `loadDeferredSectionFn` prop signature of `DecoPageRenderer`.
238
+ *
239
+ * Pass this directly to `<DecoPageRenderer loadDeferredSectionFn={deferredSectionLoader} />`
240
+ * to enable IntersectionObserver-based lazy loading of deferred sections.
241
+ */
242
+ export const deferredSectionLoader = async ({
243
+ component,
244
+ rawProps,
245
+ pagePath,
246
+ pageUrl,
247
+ }: {
248
+ component: string;
249
+ rawProps: Record<string, unknown>;
250
+ pagePath: string;
251
+ pageUrl?: string;
252
+ }): Promise<ResolvedSection | null> => {
253
+ return loadDeferredSection({
254
+ data: { component, rawProps, pagePath, pageUrl },
255
+ });
256
+ };
257
+
231
258
  // ---------------------------------------------------------------------------
232
259
  // Default pending component — shown during SPA navigation while loader runs
233
260
  // ---------------------------------------------------------------------------
@@ -8,6 +8,7 @@ export {
8
8
  type CmsRouteOptions,
9
9
  cmsHomeRouteConfig,
10
10
  cmsRouteConfig,
11
+ deferredSectionLoader,
11
12
  loadCmsHomePage,
12
13
  loadCmsPage,
13
14
  loadDeferredSection,