@decocms/blocks 7.39.0 → 7.41.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/blocks",
3
- "version": "7.39.0",
3
+ "version": "7.41.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -6,7 +6,8 @@
6
6
  * registerSectionsSync, setAsyncRenderingConfig, registerCacheableSections,
7
7
  * registerLayoutSections, registerSeoSections, and registerSection.
8
8
  */
9
- import { registerSection, registerSectionsSync } from "./registry";
9
+ import { registerSection, registerSectionsSync, setSectionRenderJson } from "./registry";
10
+ import type { RenderJson } from "./renderJson";
10
11
  import {
11
12
  type AsyncRenderingConfig,
12
13
  getAsyncRenderingConfig,
@@ -32,6 +33,10 @@ export interface SectionMetaEntry {
32
33
  clientOnly?: boolean;
33
34
  seo?: boolean;
34
35
  hasLoadingFallback?: boolean;
36
+ /** `export const renderJson = false` — drop the section from ?renderJson. */
37
+ renderJson?: false;
38
+ /** `export const renderJson = (props) => ...` — a projection fn (in `renderJsons`). */
39
+ hasRenderJson?: boolean;
35
40
  }
36
41
 
37
42
  export interface ApplySectionConventionsInput {
@@ -41,12 +46,14 @@ export interface ApplySectionConventionsInput {
41
46
  syncComponents?: Record<string, any>;
42
47
  /** LoadingFallback components from sections.gen.ts */
43
48
  loadingFallbacks?: Record<string, React.ComponentType<any>>;
49
+ /** renderJson projection functions from sections.gen.ts (?renderJson mobile path) */
50
+ renderJsons?: Record<string, RenderJson>;
44
51
  /** Lazy section loaders from import.meta.glob (used for clientOnly/loadingFallback registration) */
45
52
  sectionGlob?: Record<string, () => Promise<any>>;
46
53
  }
47
54
 
48
55
  export function applySectionConventions(input: ApplySectionConventionsInput): void {
49
- const { meta, syncComponents, loadingFallbacks, sectionGlob } = input;
56
+ const { meta, syncComponents, loadingFallbacks, renderJsons, sectionGlob } = input;
50
57
 
51
58
  const eagerSections: string[] = [];
52
59
  const neverDeferSections: string[] = [];
@@ -78,6 +85,15 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo
78
85
  });
79
86
  }
80
87
  }
88
+
89
+ // renderJson (?renderJson mobile path): a `= false` opt-out drops the
90
+ // section; a projection function trims its props. Set as a section option so
91
+ // the serializer reads it via getSectionOptions without loading the module.
92
+ if (entry.renderJson === false) {
93
+ setSectionRenderJson(key, false);
94
+ } else if (entry.hasRenderJson && renderJsons?.[key]) {
95
+ setSectionRenderJson(key, renderJsons[key]);
96
+ }
81
97
  }
82
98
 
83
99
  if (syncComponents && Object.keys(syncComponents).length > 0) {
package/src/cms/index.ts CHANGED
@@ -67,7 +67,17 @@ export {
67
67
  registerSections,
68
68
  registerSectionsSync,
69
69
  setResolvedComponent,
70
+ setSectionRenderJson,
70
71
  } from "./registry";
72
+ export type {
73
+ DeferredRef,
74
+ RenderJson,
75
+ RenderJsonModule,
76
+ SerializableSection,
77
+ SerializedSection,
78
+ SerializeOptions,
79
+ } from "./renderJson";
80
+ export { serializeRenderJson } from "./renderJson";
71
81
  export type {
72
82
  AsyncRenderingConfig,
73
83
  CommerceLoader,
@@ -1,4 +1,5 @@
1
1
  import type { ComponentType } from "react";
2
+ import type { RenderJson } from "./renderJson";
2
3
 
3
4
  export type OnBeforeResolveProps = (props: Record<string, unknown>) => Record<string, unknown>;
4
5
 
@@ -8,6 +9,8 @@ export type SectionModule = {
8
9
  onBeforeResolveProps?: OnBeforeResolveProps;
9
10
  LoadingFallback?: ComponentType<any>;
10
11
  ErrorFallback?: ComponentType<{ error: Error }>;
12
+ /** How this section renders to JSON for the mobile app (?renderJson). */
13
+ renderJson?: RenderJson;
11
14
  };
12
15
 
13
16
  type RegistryEntry = () => Promise<SectionModule>;
@@ -17,6 +20,12 @@ export interface SectionOptions {
17
20
  loadingFallback?: ComponentType<any>;
18
21
  /** Custom error fallback component for this section. */
19
22
  errorFallback?: ComponentType<{ error: Error }>;
23
+ /**
24
+ * The section's `renderJson` export (?renderJson mobile path): `false` drops
25
+ * the section from the JSON, a function projects its props. Populated by the
26
+ * generated section conventions and, as a fallback, on module preload.
27
+ */
28
+ renderJson?: RenderJson;
20
29
  /**
21
30
  * When true, the section is wrapped in `<ClientOnly>` from TanStack Router.
22
31
  * It renders only on the client — no SSR, no hydration mismatch.
@@ -72,6 +81,15 @@ export function registerSections(
72
81
  }
73
82
  }
74
83
 
84
+ /**
85
+ * Set a section's `renderJson` option (?renderJson mobile path) without touching
86
+ * its loader/component registration. Called from the generated section
87
+ * conventions for `export const renderJson = false | (props) => ...`.
88
+ */
89
+ export function setSectionRenderJson(key: string, renderJson: RenderJson): void {
90
+ sectionOptions[key] = { ...sectionOptions[key], renderJson };
91
+ }
92
+
75
93
  export function getSection(resolveType: string): RegistryEntry | undefined {
76
94
  return registry[resolveType];
77
95
  }
@@ -99,6 +117,7 @@ export async function preloadSectionModule(
99
117
  const opts: SectionOptions = { ...existing };
100
118
  if (mod.LoadingFallback) opts.loadingFallback = mod.LoadingFallback;
101
119
  if (mod.ErrorFallback) opts.errorFallback = mod.ErrorFallback;
120
+ if (mod.renderJson !== undefined) opts.renderJson = mod.renderJson;
102
121
  sectionOptions[resolveType] = opts;
103
122
  return opts;
104
123
  } catch (e) {
@@ -146,6 +165,7 @@ export async function preloadSectionComponents(keys: string[]): Promise<void> {
146
165
  const opts: SectionOptions = { ...sectionOptions[key] };
147
166
  if (mod.LoadingFallback) opts.loadingFallback = mod.LoadingFallback;
148
167
  if (mod.ErrorFallback) opts.errorFallback = mod.ErrorFallback;
168
+ if (mod.renderJson !== undefined) opts.renderJson = mod.renderJson;
149
169
  sectionOptions[key] = opts;
150
170
  } catch (e) {
151
171
  console.warn(`[Registry] Failed to preload component "${key}":`, e);
@@ -167,6 +187,7 @@ export type SyncSectionEntry =
167
187
  default: ComponentType<any>;
168
188
  LoadingFallback?: ComponentType<any>;
169
189
  ErrorFallback?: ComponentType<{ error: Error }>;
190
+ renderJson?: RenderJson;
170
191
  };
171
192
 
172
193
  /**
@@ -205,6 +226,7 @@ export function registerSectionsSync(sections: Record<string, SyncSectionEntry>)
205
226
  const opts: SectionOptions = { ...sectionOptions[key] };
206
227
  if (entry.LoadingFallback) opts.loadingFallback = entry.LoadingFallback;
207
228
  if (entry.ErrorFallback) opts.errorFallback = entry.ErrorFallback;
229
+ if (entry.renderJson !== undefined) opts.renderJson = entry.renderJson;
208
230
  sectionOptions[key] = opts;
209
231
  }
210
232
  }
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { type RenderJsonModule, serializeRenderJson } from "./renderJson";
3
+
4
+ const modules: Record<string, RenderJsonModule> = {
5
+ "site/sections/Product/ProductDetails.tsx": {
6
+ renderJson: ({ storeConfig: _s, ...rest }) => rest,
7
+ },
8
+ "site/sections/Theme/Theme.tsx": { renderJson: false },
9
+ "site/sections/Footer/Footer.tsx": {}, // no renderJson → full props
10
+ };
11
+ const getSectionModule = (c: string) => modules[c];
12
+
13
+ describe("serializeRenderJson", () => {
14
+ it("applies a projection function to the props", () => {
15
+ const out = serializeRenderJson(
16
+ [{ component: "site/sections/Product/ProductDetails.tsx", props: { storeConfig: {}, page: 1 } }],
17
+ { getSectionModule },
18
+ );
19
+ expect(out).toEqual([
20
+ { component: "site/sections/Product/ProductDetails.tsx", props: { page: 1 } },
21
+ ]);
22
+ });
23
+
24
+ it("drops sections whose module exports renderJson === false", () => {
25
+ const out = serializeRenderJson(
26
+ [
27
+ { component: "site/sections/Theme/Theme.tsx", props: { x: 1 } },
28
+ { component: "site/sections/Footer/Footer.tsx", props: { y: 2 } },
29
+ ],
30
+ { getSectionModule },
31
+ );
32
+ expect(out).toEqual([{ component: "site/sections/Footer/Footer.tsx", props: { y: 2 } }]);
33
+ });
34
+
35
+ it("passes full props through when there is no renderJson export", () => {
36
+ const out = serializeRenderJson([{ component: "site/sections/Footer/Footer.tsx", props: { y: 2 } }], {
37
+ getSectionModule,
38
+ });
39
+ expect(out).toEqual([{ component: "site/sections/Footer/Footer.tsx", props: { y: 2 } }]);
40
+ });
41
+
42
+ it("drops sections by sectionsToIgnore suffix match on resolveType", () => {
43
+ const out = serializeRenderJson(
44
+ [
45
+ { component: "website/sections/Seo/SeoV2.tsx", props: { title: "x" } },
46
+ { component: "site/sections/Footer/Footer.tsx", props: { y: 2 } },
47
+ ],
48
+ { getSectionModule, sectionsToIgnore: ["SeoV2.tsx"] },
49
+ );
50
+ expect(out).toEqual([{ component: "site/sections/Footer/Footer.tsx", props: { y: 2 } }]);
51
+ });
52
+
53
+ it("ignores blank sectionsToIgnore entries (a '' suffix must not drop everything)", () => {
54
+ const out = serializeRenderJson(
55
+ [{ component: "site/sections/Footer/Footer.tsx", props: { y: 2 } }],
56
+ { getSectionModule, sectionsToIgnore: ["", " "] },
57
+ );
58
+ expect(out).toHaveLength(1);
59
+ });
60
+
61
+ it("keeps full props when no getSectionModule is provided", () => {
62
+ const out = serializeRenderJson([{ component: "a", props: { z: 1 } }]);
63
+ expect(out).toEqual([{ component: "a", props: { z: 1 } }]);
64
+ });
65
+
66
+ it("defaults missing props to an empty object", () => {
67
+ const out = serializeRenderJson([{ component: "a" }]);
68
+ expect(out).toEqual([{ component: "a", props: {} }]);
69
+ });
70
+
71
+ it("interleaves deferred sections as { component, lazyUrl } by index", () => {
72
+ const out = serializeRenderJson(
73
+ [
74
+ { component: "site/sections/Footer/Footer.tsx", props: { y: 2 }, index: 2 },
75
+ { component: "site/sections/Header.tsx", props: { h: 1 }, index: 0 },
76
+ ],
77
+ {
78
+ getSectionModule,
79
+ deferred: [{ component: "site/sections/Shelf.tsx", index: 1 }],
80
+ lazyUrlFor: (ref) => `/p?renderJson&__lazy=${ref.index}`,
81
+ },
82
+ );
83
+ expect(out).toEqual([
84
+ { component: "site/sections/Header.tsx", props: { h: 1 } },
85
+ { component: "site/sections/Shelf.tsx", lazyUrl: "/p?renderJson&__lazy=1" },
86
+ { component: "site/sections/Footer/Footer.tsx", props: { y: 2 } },
87
+ ]);
88
+ });
89
+
90
+ it("does not emit a lazyUrl for a dropped deferred section", () => {
91
+ const out = serializeRenderJson([{ component: "site/sections/Header.tsx", props: {}, index: 0 }], {
92
+ getSectionModule,
93
+ sectionsToIgnore: ["SeoV2.tsx"],
94
+ deferred: [
95
+ { component: "website/sections/Seo/SeoV2.tsx", index: 1 }, // suffix-dropped
96
+ { component: "site/sections/Theme/Theme.tsx", index: 2 }, // renderJson === false
97
+ ],
98
+ lazyUrlFor: (ref) => `/p?renderJson&__lazy=${ref.index}`,
99
+ });
100
+ expect(out).toEqual([{ component: "site/sections/Header.tsx", props: {} }]);
101
+ });
102
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * renderJson — structured JSON projection of a resolved page for the mobile app.
3
+ *
4
+ * A section controls how it renders to JSON through a recognized named export
5
+ * `renderJson` (same convention family as `loader`/`action`/`LoadingFallback`):
6
+ *
7
+ * - `export const renderJson = false` — drop the section from the JSON entirely
8
+ * (web-only: theme/analytics/SEO/scripts). It also short-circuits the loader
9
+ * in {@link runSectionLoaders} (no data fetch for a dropped section).
10
+ * - `export const renderJson = (props) => projected` — a pure projection applied
11
+ * to the resolved props before serialization (typically {@link deepOmit} of
12
+ * internal/SEO/store-config fields).
13
+ * - no export — the section serializes with its full resolved props.
14
+ *
15
+ * The whole-page envelope (`?renderJson`) is `{ name, path, sections }`, where
16
+ * each section is `{ component, props }`. This is the lean shape — no admin
17
+ * `resolveChain`/metadata (unlike the legacy `?asJson`).
18
+ *
19
+ * Ported from the deco-runtime fork's `serialize-section.ts`, adapted for the
20
+ * TanStack pipeline: sections are resolved EAGERLY before serialization, so this
21
+ * serializer has no lazy-placeholder (`{ component, lazyUrl }`) branch — the
22
+ * mobile app receives the whole page in one request.
23
+ *
24
+ * ponytail: no nested section-in-section recursion (composite/layout sections).
25
+ * deepOmit handles trimming within a single section's data; add a recursive walk
26
+ * here if a composite section must project its child sections.
27
+ */
28
+
29
+ // deno-lint-ignore-style any: a projection accepts the section's own resolved props.
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ export type RenderJson = ((props: any) => Record<string, unknown>) | false;
32
+
33
+ export interface RenderJsonModule {
34
+ renderJson?: RenderJson;
35
+ }
36
+
37
+ /** A resolved section after its loader ran: component id + enriched props. */
38
+ export interface SerializableSection {
39
+ component: string;
40
+ props?: Record<string, unknown>;
41
+ /** Position in the page's flat section list (used to interleave with lazy ones). */
42
+ index?: number;
43
+ }
44
+
45
+ /** A deferred section that isn't resolved eagerly — emitted as a lazy placeholder. */
46
+ export interface DeferredRef {
47
+ component: string;
48
+ index: number;
49
+ }
50
+
51
+ /** An eager section carries `props`; a lazy one carries a `lazyUrl` to fetch it. */
52
+ export type SerializedSection =
53
+ | { component: string; props: Record<string, unknown> }
54
+ | { component: string; lazyUrl: string };
55
+
56
+ export interface SerializeOptions {
57
+ /**
58
+ * Resolves a section module by resolveType so the serializer can honor its
59
+ * `renderJson` export. When omitted, every section keeps its full props.
60
+ */
61
+ getSectionModule?: (component: string) => RenderJsonModule | undefined;
62
+ /**
63
+ * App-owned sections excluded from the response, matched by resolveType
64
+ * suffix (e.g. "SeoV2.tsx"). Blank entries are ignored — a "" suffix would
65
+ * endsWith-match every section.
66
+ */
67
+ sectionsToIgnore?: string[];
68
+ /**
69
+ * Deferred sections to emit as `{ component, lazyUrl }` placeholders,
70
+ * interleaved with the eager ones by `index`. Dropped sections
71
+ * (`renderJson === false` / `sectionsToIgnore`) are omitted here too. When
72
+ * set, the output is ordered by `index`.
73
+ */
74
+ deferred?: DeferredRef[];
75
+ /** Builds the lazy-fetch URL for a deferred section. Required when `deferred` is set. */
76
+ lazyUrlFor?: (ref: DeferredRef) => string;
77
+ }
78
+
79
+ /**
80
+ * Projects a page's sections into the lean renderJson shape. Eager sections
81
+ * become `{ component, props }` (a `renderJson` function projects the props;
82
+ * otherwise props pass through). Deferred sections become
83
+ * `{ component, lazyUrl }`, interleaved by `index`. A section is dropped when
84
+ * its resolveType matches a `sectionsToIgnore` suffix OR its module exports
85
+ * `renderJson === false` (for a deferred section, dropping avoids emitting a
86
+ * lazyUrl the app would fetch for nothing).
87
+ */
88
+ export function serializeRenderJson(
89
+ sections: SerializableSection[],
90
+ opts: SerializeOptions = {},
91
+ ): SerializedSection[] {
92
+ const ignore = (opts.sectionsToIgnore ?? [])
93
+ .map((s) => s.trim())
94
+ .filter((s) => s.length > 0);
95
+
96
+ const renderJsonOf = (component: string): RenderJson | undefined =>
97
+ opts.getSectionModule?.(component)?.renderJson;
98
+
99
+ const isDropped = (component: string): boolean =>
100
+ ignore.some((suffix) => component.endsWith(suffix)) ||
101
+ renderJsonOf(component) === false;
102
+
103
+ const eager = sections
104
+ .filter((s) => !isDropped(s.component))
105
+ .map((section) => {
106
+ const rj = renderJsonOf(section.component);
107
+ const props = typeof rj === "function" ? rj(section.props ?? {}) : section.props ?? {};
108
+ return { index: section.index, out: { component: section.component, props } as SerializedSection };
109
+ });
110
+
111
+ // No deferred sections → preserve input order (backward compatible).
112
+ if (!opts.deferred || opts.deferred.length === 0) {
113
+ return eager.map((e) => e.out);
114
+ }
115
+
116
+ const lazyUrlFor = opts.lazyUrlFor;
117
+ const lazy = opts.deferred
118
+ .filter((ref) => !isDropped(ref.component) && !!lazyUrlFor)
119
+ .map((ref) => ({
120
+ index: ref.index,
121
+ out: { component: ref.component, lazyUrl: lazyUrlFor!(ref) } as SerializedSection,
122
+ }));
123
+
124
+ // Interleave eager + lazy by their position in the page's flat section list.
125
+ return [...eager, ...lazy].sort((a, b) => (a.index ?? 0) - (b.index ?? 0)).map((e) => e.out);
126
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { deepOmit } from "./deepOmit";
3
+
4
+ describe("deepOmit", () => {
5
+ it("removes a top-level key immutably", () => {
6
+ const input = { a: 1, b: 2 };
7
+ const out = deepOmit(input, "b");
8
+ expect(out).toEqual({ a: 1 });
9
+ expect(input).toEqual({ a: 1, b: 2 }); // input untouched
10
+ });
11
+
12
+ it("removes a nested dotted path", () => {
13
+ const out = deepOmit({ page: { seo: { title: "x" }, id: 1 } }, "page.seo");
14
+ expect(out).toEqual({ page: { id: 1 } });
15
+ });
16
+
17
+ it("fans a wildcard over record values", () => {
18
+ const out = deepOmit(
19
+ { page: { productsMap: { a: { keep: 1, drop: 2 }, b: { keep: 3, drop: 4 } } } },
20
+ "page.productsMap.*.drop",
21
+ );
22
+ expect(out).toEqual({ page: { productsMap: { a: { keep: 1 }, b: { keep: 3 } } } });
23
+ });
24
+
25
+ it("fans a wildcard over array elements", () => {
26
+ const out = deepOmit({ items: [{ keep: 1, drop: 2 }, { keep: 3, drop: 4 }] }, "items.*.drop");
27
+ expect(out).toEqual({ items: [{ keep: 1 }, { keep: 3 }] });
28
+ });
29
+
30
+ it("auto-applies a path to each array element (no explicit *)", () => {
31
+ const out = deepOmit({ items: [{ a: 1, b: 2 }, { a: 3, b: 4 }] }, "items.b");
32
+ expect(out).toEqual({ items: [{ a: 1 }, { a: 3 }] });
33
+ });
34
+
35
+ it("is a no-op for a missing key (never fabricates undefined)", () => {
36
+ const out = deepOmit({ a: 1 }, "b.c.d");
37
+ expect(out).toEqual({ a: 1 });
38
+ expect("b" in out).toBe(false);
39
+ });
40
+
41
+ it("applies multiple paths", () => {
42
+ const out = deepOmit({ a: 1, b: 2, c: { d: 3, e: 4 } }, "a", "c.d");
43
+ expect(out).toEqual({ b: 2, c: { e: 4 } });
44
+ });
45
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * deepOmit — immutably remove dotted paths from an object tree.
3
+ *
4
+ * Used by section `renderJson` projections to trim a section's props before it
5
+ * is serialized for the mobile app (the ?renderJson page-as-JSON path):
6
+ *
7
+ * ```ts
8
+ * export const renderJson = (props: SectionProps<typeof loader>) =>
9
+ * deepOmit(props, "storeConfig", "page.seo", "page.productsMap.*.hasFetchedSimilars");
10
+ * ```
11
+ *
12
+ * Semantics:
13
+ * - Top-level key: `deepOmit(o, "seoProps")`.
14
+ * - Nested path (dot-separated): `deepOmit(o, "page.seo")`.
15
+ * - Wildcard `*`: fans the remaining path over every array element OR record
16
+ * value — `"page.productsMap.*.hasFetchedSimilars"` strips `hasFetchedSimilars`
17
+ * from every value of `productsMap`.
18
+ * - Arrays without an explicit `*`: the path auto-applies to each element and the
19
+ * array shape is preserved.
20
+ * - Missing key: a no-op — never fabricates an `undefined` branch.
21
+ *
22
+ * Pure: spreads/copies at each level, never mutates the input.
23
+ */
24
+ const omitAtPath = (obj: unknown, parts: string[]): unknown => {
25
+ if (!obj || typeof obj !== "object" || parts.length === 0) return obj;
26
+ const [key, ...rest] = parts;
27
+
28
+ // `*` fans the remaining path out over every array element / record value.
29
+ if (key === "*") {
30
+ if (Array.isArray(obj)) return obj.map((v) => omitAtPath(v, rest));
31
+ return Object.fromEntries(
32
+ Object.entries(obj as Record<string, unknown>).map(([k, v]) => [k, omitAtPath(v, rest)]),
33
+ );
34
+ }
35
+ // Preserve array shape: apply the same path to each element.
36
+ if (Array.isArray(obj)) return obj.map((v) => omitAtPath(v, parts));
37
+
38
+ const current = obj as Record<string, unknown>;
39
+ if (rest.length === 0) {
40
+ const copy = { ...current };
41
+ delete copy[key];
42
+ return copy;
43
+ }
44
+ if (!(key in current)) return current; // absent path = no-op, never creates undefined branches
45
+ return { ...current, [key]: omitAtPath(current[key], rest) };
46
+ };
47
+
48
+ export const deepOmit = <T extends object>(obj: T, ...paths: string[]): T => {
49
+ let result: unknown = obj;
50
+ for (const path of paths) result = omitAtPath(result, path.split("."));
51
+ return result as T;
52
+ };
package/src/sdk/index.ts CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  export { clx } from "./clx";
19
19
  export { decodeCookie, deleteCookie, getCookie, getServerSideCookie, setCookie } from "./cookie";
20
20
  export { buildCSPHeaderValue, type CSPOptions, setCSPHeaders } from "./csp";
21
+ export { deepOmit } from "./deepOmit";
21
22
  export { djb2, djb2Hex } from "./djb2";
22
23
  export { isDevMode } from "./env";
23
24
  export {