@decocms/blocks 7.38.0 → 7.40.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 +1 -1
- package/src/cms/applySectionConventions.ts +18 -2
- package/src/cms/index.ts +10 -0
- package/src/cms/registry.ts +22 -0
- package/src/cms/renderJson.test.ts +102 -0
- package/src/cms/renderJson.ts +126 -0
- package/src/sdk/deepOmit.test.ts +45 -0
- package/src/sdk/deepOmit.ts +52 -0
- package/src/sdk/index.ts +1 -0
package/package.json
CHANGED
|
@@ -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,
|
package/src/cms/registry.ts
CHANGED
|
@@ -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 {
|