@decocms/start 2.2.0 → 2.4.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/routes/index.ts +7 -2
- package/src/routes/withSiteGlobals.test.ts +272 -0
- package/src/routes/withSiteGlobals.ts +228 -0
- package/src/sdk/index.ts +21 -7
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +38 -22
package/package.json
CHANGED
package/src/routes/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export type { PageSeo } from "../cms/resolve";
|
|
2
|
+
export type { Device } from "../sdk/useDevice";
|
|
1
3
|
export {
|
|
2
4
|
decoInvokeRoute,
|
|
3
5
|
decoMetaRoute,
|
|
@@ -15,5 +17,8 @@ export {
|
|
|
15
17
|
setSectionChunkMap,
|
|
16
18
|
} from "./cmsRoute";
|
|
17
19
|
export { CmsPage, NotFoundPage } from "./components";
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
+
export {
|
|
21
|
+
resolveSiteGlobals,
|
|
22
|
+
type SiteGlobalsLoaderData,
|
|
23
|
+
withSiteGlobals,
|
|
24
|
+
} from "./withSiteGlobals";
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const { onChangeListeners } = vi.hoisted(() => ({
|
|
4
|
+
onChangeListeners: [] as Array<() => void>,
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock("../cms", () => ({
|
|
8
|
+
loadBlocks: vi.fn(),
|
|
9
|
+
onChange: vi.fn((listener: () => void) => {
|
|
10
|
+
onChangeListeners.push(listener);
|
|
11
|
+
}),
|
|
12
|
+
resolvePageSections: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import { loadBlocks, resolvePageSections } from "../cms";
|
|
16
|
+
import { __resetSiteGlobalsCache, resolveSiteGlobals, withSiteGlobals } from "./withSiteGlobals";
|
|
17
|
+
|
|
18
|
+
const mockedLoadBlocks = loadBlocks as unknown as ReturnType<typeof vi.fn>;
|
|
19
|
+
const mockedResolvePageSections = resolvePageSections as unknown as ReturnType<typeof vi.fn>;
|
|
20
|
+
|
|
21
|
+
describe("withSiteGlobals", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
__resetSiteGlobalsCache();
|
|
24
|
+
mockedLoadBlocks.mockReset();
|
|
25
|
+
mockedResolvePageSections.mockReset();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("resolveSiteGlobals", () => {
|
|
29
|
+
it("returns empty when there is no Site block", async () => {
|
|
30
|
+
mockedLoadBlocks.mockReturnValue({});
|
|
31
|
+
const result = await resolveSiteGlobals();
|
|
32
|
+
expect(result.resolvedSections).toEqual([]);
|
|
33
|
+
expect(result.rawRefs).toEqual([]);
|
|
34
|
+
expect(mockedResolvePageSections).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns empty when Site block has no globals", async () => {
|
|
38
|
+
mockedLoadBlocks.mockReturnValue({ site: { seo: { title: "x" } } });
|
|
39
|
+
const result = await resolveSiteGlobals();
|
|
40
|
+
expect(result.resolvedSections).toEqual([]);
|
|
41
|
+
expect(result.rawRefs).toEqual([]);
|
|
42
|
+
expect(mockedResolvePageSections).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("gathers theme + global + pageSections in order", async () => {
|
|
46
|
+
mockedLoadBlocks.mockReturnValue({
|
|
47
|
+
site: {
|
|
48
|
+
theme: { __resolveType: "Theme" },
|
|
49
|
+
global: [{ __resolveType: "Analytics" }, { __resolveType: "WishlistProvider" }],
|
|
50
|
+
pageSections: [{ __resolveType: "Session" }],
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const resolved = [
|
|
54
|
+
{ component: "Theme.tsx", props: {}, key: "k0" },
|
|
55
|
+
{ component: "Analytics.tsx", props: {}, key: "k1" },
|
|
56
|
+
{ component: "Wishlist.tsx", props: {}, key: "k2" },
|
|
57
|
+
{ component: "Session.tsx", props: {}, key: "k3" },
|
|
58
|
+
];
|
|
59
|
+
mockedResolvePageSections.mockResolvedValue(resolved);
|
|
60
|
+
|
|
61
|
+
const result = await resolveSiteGlobals();
|
|
62
|
+
|
|
63
|
+
expect(result.rawRefs).toEqual([
|
|
64
|
+
{ __resolveType: "Theme" },
|
|
65
|
+
{ __resolveType: "Analytics" },
|
|
66
|
+
{ __resolveType: "WishlistProvider" },
|
|
67
|
+
{ __resolveType: "Session" },
|
|
68
|
+
]);
|
|
69
|
+
expect(result.resolvedSections).toEqual(resolved);
|
|
70
|
+
expect(mockedResolvePageSections).toHaveBeenCalledTimes(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("accepts both `site` (lowercase) and `Site` (PascalCase) block keys", async () => {
|
|
74
|
+
mockedLoadBlocks.mockReturnValue({
|
|
75
|
+
Site: { theme: { __resolveType: "Theme" } },
|
|
76
|
+
});
|
|
77
|
+
mockedResolvePageSections.mockResolvedValue([
|
|
78
|
+
{ component: "Theme.tsx", props: {}, key: "k0" },
|
|
79
|
+
]);
|
|
80
|
+
const result = await resolveSiteGlobals();
|
|
81
|
+
expect(result.rawRefs).toEqual([{ __resolveType: "Theme" }]);
|
|
82
|
+
expect(result.resolvedSections).toHaveLength(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("dedupes inflight requests (single resolvePageSections call for parallel callers)", async () => {
|
|
86
|
+
mockedLoadBlocks.mockReturnValue({
|
|
87
|
+
site: { global: [{ __resolveType: "Analytics" }] },
|
|
88
|
+
});
|
|
89
|
+
let resolveFn!: (v: unknown[]) => void;
|
|
90
|
+
mockedResolvePageSections.mockImplementation(
|
|
91
|
+
() =>
|
|
92
|
+
new Promise((res) => {
|
|
93
|
+
resolveFn = res as any;
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const a = resolveSiteGlobals();
|
|
98
|
+
const b = resolveSiteGlobals();
|
|
99
|
+
resolveFn([{ component: "A.tsx", props: {}, key: "k0" }]);
|
|
100
|
+
const [ra, rb] = await Promise.all([a, b]);
|
|
101
|
+
|
|
102
|
+
expect(ra).toEqual(rb);
|
|
103
|
+
expect(mockedResolvePageSections).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("caches across calls within TTL", async () => {
|
|
107
|
+
mockedLoadBlocks.mockReturnValue({
|
|
108
|
+
site: { global: [{ __resolveType: "Analytics" }] },
|
|
109
|
+
});
|
|
110
|
+
mockedResolvePageSections.mockResolvedValue([{ component: "A.tsx", props: {}, key: "k0" }]);
|
|
111
|
+
|
|
112
|
+
await resolveSiteGlobals();
|
|
113
|
+
await resolveSiteGlobals();
|
|
114
|
+
await resolveSiteGlobals();
|
|
115
|
+
|
|
116
|
+
expect(mockedResolvePageSections).toHaveBeenCalledTimes(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("invalidates cache when onChange fires", async () => {
|
|
120
|
+
mockedLoadBlocks.mockReturnValue({
|
|
121
|
+
site: { global: [{ __resolveType: "Analytics" }] },
|
|
122
|
+
});
|
|
123
|
+
mockedResolvePageSections.mockResolvedValue([{ component: "A.tsx", props: {}, key: "k0" }]);
|
|
124
|
+
|
|
125
|
+
await resolveSiteGlobals();
|
|
126
|
+
expect(mockedResolvePageSections).toHaveBeenCalledTimes(1);
|
|
127
|
+
|
|
128
|
+
// Simulate a CMS reload
|
|
129
|
+
for (const listener of onChangeListeners) listener();
|
|
130
|
+
|
|
131
|
+
await resolveSiteGlobals();
|
|
132
|
+
expect(mockedResolvePageSections).toHaveBeenCalledTimes(2);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("does not cache failures (next call retries)", async () => {
|
|
136
|
+
mockedLoadBlocks.mockReturnValue({
|
|
137
|
+
site: { global: [{ __resolveType: "Analytics" }] },
|
|
138
|
+
});
|
|
139
|
+
mockedResolvePageSections
|
|
140
|
+
.mockRejectedValueOnce(new Error("boom"))
|
|
141
|
+
.mockResolvedValueOnce([{ component: "A.tsx", props: {}, key: "k0" }]);
|
|
142
|
+
|
|
143
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
144
|
+
const first = await resolveSiteGlobals();
|
|
145
|
+
expect(first.resolvedSections).toEqual([]);
|
|
146
|
+
|
|
147
|
+
const second = await resolveSiteGlobals();
|
|
148
|
+
expect(second.resolvedSections).toHaveLength(1);
|
|
149
|
+
expect(mockedResolvePageSections).toHaveBeenCalledTimes(2);
|
|
150
|
+
errSpy.mockRestore();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("withSiteGlobals wrapper", () => {
|
|
155
|
+
it("passes through null page (404) without merging globals", async () => {
|
|
156
|
+
mockedLoadBlocks.mockReturnValue({});
|
|
157
|
+
const baseLoader = vi.fn().mockResolvedValue(null);
|
|
158
|
+
const cfg = withSiteGlobals({ loader: baseLoader });
|
|
159
|
+
const result = await cfg.loader();
|
|
160
|
+
expect(result).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("merges resolved globals BEFORE page sections", async () => {
|
|
164
|
+
mockedLoadBlocks.mockReturnValue({
|
|
165
|
+
site: { theme: { __resolveType: "Theme" } },
|
|
166
|
+
});
|
|
167
|
+
mockedResolvePageSections.mockResolvedValue([
|
|
168
|
+
{ component: "Theme.tsx", props: {}, key: "g0" },
|
|
169
|
+
]);
|
|
170
|
+
const baseLoader = vi.fn().mockResolvedValue({
|
|
171
|
+
resolvedSections: [
|
|
172
|
+
{ component: "Header.tsx", props: {}, key: "p0" },
|
|
173
|
+
{ component: "Hero.tsx", props: {}, key: "p1" },
|
|
174
|
+
],
|
|
175
|
+
// arbitrary other route fields preserved
|
|
176
|
+
cacheProfile: "static",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const cfg = withSiteGlobals({ loader: baseLoader });
|
|
180
|
+
const result = await cfg.loader();
|
|
181
|
+
|
|
182
|
+
expect(result.resolvedSections.map((s: any) => s.component)).toEqual([
|
|
183
|
+
"Theme.tsx",
|
|
184
|
+
"Header.tsx",
|
|
185
|
+
"Hero.tsx",
|
|
186
|
+
]);
|
|
187
|
+
expect(result.cacheProfile).toBe("static");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("dedupes globals whose component already appears on the page", async () => {
|
|
191
|
+
mockedLoadBlocks.mockReturnValue({
|
|
192
|
+
site: {
|
|
193
|
+
global: [{ __resolveType: "Session" }, { __resolveType: "Theme" }],
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
mockedResolvePageSections.mockResolvedValue([
|
|
197
|
+
{ component: "Session.tsx", props: {}, key: "g0" },
|
|
198
|
+
{ component: "Theme.tsx", props: {}, key: "g1" },
|
|
199
|
+
]);
|
|
200
|
+
const baseLoader = vi.fn().mockResolvedValue({
|
|
201
|
+
// Page already mounts Session — global Session should NOT duplicate.
|
|
202
|
+
resolvedSections: [{ component: "Session.tsx", props: { fromPage: true }, key: "p0" }],
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const cfg = withSiteGlobals({ loader: baseLoader });
|
|
206
|
+
const result = await cfg.loader();
|
|
207
|
+
|
|
208
|
+
const components = result.resolvedSections.map((s: any) => s.component);
|
|
209
|
+
// Only one Session, taken from the page (page-level wins).
|
|
210
|
+
expect(components).toEqual(["Theme.tsx", "Session.tsx"]);
|
|
211
|
+
const session = result.resolvedSections.find((s: any) => s.component === "Session.tsx");
|
|
212
|
+
expect(session.props.fromPage).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("dedupes within globals (Session in both site.global AND site.pageSections)", async () => {
|
|
216
|
+
mockedLoadBlocks.mockReturnValue({
|
|
217
|
+
site: {
|
|
218
|
+
global: [{ __resolveType: "Session" }],
|
|
219
|
+
pageSections: [{ __resolveType: "Session" }],
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
mockedResolvePageSections.mockResolvedValue([
|
|
223
|
+
{ component: "Session.tsx", props: { from: "global" }, key: "g0" },
|
|
224
|
+
{ component: "Session.tsx", props: { from: "pageSections" }, key: "g1" },
|
|
225
|
+
]);
|
|
226
|
+
const baseLoader = vi.fn().mockResolvedValue({ resolvedSections: [] });
|
|
227
|
+
|
|
228
|
+
const cfg = withSiteGlobals({ loader: baseLoader });
|
|
229
|
+
const result = await cfg.loader();
|
|
230
|
+
|
|
231
|
+
// Only one Session ends up in the final tree (first-wins within globals).
|
|
232
|
+
expect(result.resolvedSections).toHaveLength(1);
|
|
233
|
+
expect(result.resolvedSections[0].props.from).toBe("global");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("attaches siteGlobals.rawRefs for site to read head-injection data", async () => {
|
|
237
|
+
const analyticsRef = {
|
|
238
|
+
__resolveType: "website/sections/Analytics/Analytics.tsx",
|
|
239
|
+
trackingIds: ["GTM-ABC"],
|
|
240
|
+
};
|
|
241
|
+
mockedLoadBlocks.mockReturnValue({
|
|
242
|
+
site: { global: [analyticsRef] },
|
|
243
|
+
});
|
|
244
|
+
mockedResolvePageSections.mockResolvedValue([]);
|
|
245
|
+
const baseLoader = vi.fn().mockResolvedValue({ resolvedSections: [] });
|
|
246
|
+
|
|
247
|
+
const cfg = withSiteGlobals({ loader: baseLoader });
|
|
248
|
+
const result = await cfg.loader();
|
|
249
|
+
|
|
250
|
+
expect(result.siteGlobals).toEqual({ rawRefs: [analyticsRef] });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("preserves wrapped loader's other return fields", async () => {
|
|
254
|
+
mockedLoadBlocks.mockReturnValue({});
|
|
255
|
+
const baseLoader = vi.fn().mockResolvedValue({
|
|
256
|
+
resolvedSections: [],
|
|
257
|
+
seo: { title: "Hello" },
|
|
258
|
+
cacheProfile: "product",
|
|
259
|
+
device: "mobile",
|
|
260
|
+
pageUrl: "https://store.com/x",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const cfg = withSiteGlobals({ loader: baseLoader });
|
|
264
|
+
const result = await cfg.loader();
|
|
265
|
+
|
|
266
|
+
expect(result.seo).toEqual({ title: "Hello" });
|
|
267
|
+
expect(result.cacheProfile).toBe("product");
|
|
268
|
+
expect(result.device).toBe("mobile");
|
|
269
|
+
expect(result.pageUrl).toBe("https://store.com/x");
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site Globals Wrapper
|
|
3
|
+
*
|
|
4
|
+
* Opt-in helper that merges sections declared in the CMS `Site` block
|
|
5
|
+
* (`site.theme + site.global + site.pageSections`) into every page's
|
|
6
|
+
* `resolvedSections` array.
|
|
7
|
+
*
|
|
8
|
+
* Without this wrapper, only `site.seo` is consumed by `cmsRouteConfig` —
|
|
9
|
+
* the rest of the Site block is dormant CMS data. Sites that declare
|
|
10
|
+
* theme/analytics/wishlist/help-button blocks at the site level (rather
|
|
11
|
+
* than per-page) can opt in here to have them rendered automatically.
|
|
12
|
+
*
|
|
13
|
+
* @example Site's `src/routes/$.tsx`:
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { createFileRoute, notFound } from "@tanstack/react-router";
|
|
16
|
+
* import { cmsRouteConfig, withSiteGlobals } from "@decocms/start/routes";
|
|
17
|
+
*
|
|
18
|
+
* export const Route = createFileRoute("/$")({
|
|
19
|
+
* ...withSiteGlobals(cmsRouteConfig({
|
|
20
|
+
* siteName: "Bagaggio",
|
|
21
|
+
* defaultTitle: "Bagaggio",
|
|
22
|
+
* })),
|
|
23
|
+
* component: ...,
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { ResolvedSection } from "../cms";
|
|
29
|
+
import { loadBlocks, onChange, resolvePageSections } from "../cms";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Types
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** Loader output additions when `withSiteGlobals` is applied. */
|
|
36
|
+
export interface SiteGlobalsLoaderData {
|
|
37
|
+
/**
|
|
38
|
+
* Raw refs (before resolution) declared in `site.theme`, `site.global`, and
|
|
39
|
+
* `site.pageSections`. Includes refs for sections that don't resolve into
|
|
40
|
+
* the section tree (`SKIP_RESOLVE_TYPES`) — useful for sites that need to
|
|
41
|
+
* read site-level data (analytics IDs, manifest config, etc.) outside the
|
|
42
|
+
* normal section render path.
|
|
43
|
+
*
|
|
44
|
+
* Ordering: `theme`, then `global`, then `pageSections`.
|
|
45
|
+
*/
|
|
46
|
+
rawRefs: unknown[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface SiteBlock {
|
|
50
|
+
theme?: unknown;
|
|
51
|
+
global?: unknown[];
|
|
52
|
+
pageSections?: unknown[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface CacheEntry {
|
|
56
|
+
resolvedSections: ResolvedSection[];
|
|
57
|
+
rawRefs: unknown[];
|
|
58
|
+
expiresAt: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Globals resolution (cached, with onChange invalidation)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
const DEFAULT_CACHE_TTL_MS = 5 * 60_000;
|
|
66
|
+
const cacheTtlMs = DEFAULT_CACHE_TTL_MS;
|
|
67
|
+
|
|
68
|
+
let cache: CacheEntry | null = null;
|
|
69
|
+
let inflight: Promise<CacheEntry> | null = null;
|
|
70
|
+
|
|
71
|
+
onChange(() => {
|
|
72
|
+
cache = null;
|
|
73
|
+
inflight = null;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function readSiteBlock(): SiteBlock | null {
|
|
77
|
+
const blocks = loadBlocks();
|
|
78
|
+
// Block keys vary by site convention — try both common cases.
|
|
79
|
+
const site = (blocks.site ?? blocks.Site) as SiteBlock | undefined;
|
|
80
|
+
return site ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function gatherSectionRefs(site: SiteBlock): unknown[] {
|
|
84
|
+
const refs: unknown[] = [];
|
|
85
|
+
if (site.theme) refs.push(site.theme);
|
|
86
|
+
if (Array.isArray(site.global)) refs.push(...site.global);
|
|
87
|
+
if (Array.isArray(site.pageSections)) refs.push(...site.pageSections);
|
|
88
|
+
return refs;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const EMPTY_ENTRY: CacheEntry = {
|
|
92
|
+
resolvedSections: [],
|
|
93
|
+
rawRefs: [],
|
|
94
|
+
expiresAt: Number.POSITIVE_INFINITY, // empty entries don't need refresh
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve `site.theme + site.global + site.pageSections` into a list of
|
|
99
|
+
* `ResolvedSection`s, with in-flight dedup and 5-minute SWR caching.
|
|
100
|
+
*
|
|
101
|
+
* Cache is invalidated by `onChange()` from the CMS loader, so admin edits
|
|
102
|
+
* and decofile reloads are reflected on the next request.
|
|
103
|
+
*
|
|
104
|
+
* Exposed as a util so sites can call it directly if they need globals
|
|
105
|
+
* outside the route loader path (rare).
|
|
106
|
+
*/
|
|
107
|
+
export async function resolveSiteGlobals(): Promise<{
|
|
108
|
+
resolvedSections: ResolvedSection[];
|
|
109
|
+
rawRefs: unknown[];
|
|
110
|
+
}> {
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
if (cache && cache.expiresAt > now) return cache;
|
|
113
|
+
if (inflight) return inflight;
|
|
114
|
+
|
|
115
|
+
const site = readSiteBlock();
|
|
116
|
+
if (!site) return EMPTY_ENTRY;
|
|
117
|
+
|
|
118
|
+
const rawRefs = gatherSectionRefs(site);
|
|
119
|
+
if (rawRefs.length === 0) return EMPTY_ENTRY;
|
|
120
|
+
|
|
121
|
+
inflight = (async () => {
|
|
122
|
+
try {
|
|
123
|
+
const resolvedSections = await resolvePageSections(rawRefs);
|
|
124
|
+
const entry: CacheEntry = {
|
|
125
|
+
resolvedSections,
|
|
126
|
+
rawRefs,
|
|
127
|
+
expiresAt: Date.now() + cacheTtlMs,
|
|
128
|
+
};
|
|
129
|
+
cache = entry;
|
|
130
|
+
return entry;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error("[site-globals] failed to resolve:", err);
|
|
133
|
+
// Don't cache failures — let the next request retry.
|
|
134
|
+
return { resolvedSections: [], rawRefs, expiresAt: 0 };
|
|
135
|
+
} finally {
|
|
136
|
+
inflight = null;
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
139
|
+
|
|
140
|
+
return inflight;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Dedupe — collapse global/pageSection components that also exist on the page
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Filter `globals` to remove sections whose `component` already appears in
|
|
149
|
+
* `existing` (page-level sections). Page sections take precedence — globals
|
|
150
|
+
* that conflict are dropped.
|
|
151
|
+
*
|
|
152
|
+
* This collapses the common case where a section like `Session` is declared
|
|
153
|
+
* both in `site.global` and in a page's section list, which would otherwise
|
|
154
|
+
* render twice.
|
|
155
|
+
*/
|
|
156
|
+
function dedupeGlobals(globals: ResolvedSection[], existing: ResolvedSection[]): ResolvedSection[] {
|
|
157
|
+
if (globals.length === 0) return [];
|
|
158
|
+
const seenComponents = new Set<string>();
|
|
159
|
+
for (const s of existing) {
|
|
160
|
+
if (typeof s.component === "string") seenComponents.add(s.component);
|
|
161
|
+
}
|
|
162
|
+
const result: ResolvedSection[] = [];
|
|
163
|
+
for (const s of globals) {
|
|
164
|
+
if (typeof s.component === "string") {
|
|
165
|
+
if (seenComponents.has(s.component)) continue;
|
|
166
|
+
seenComponents.add(s.component); // also dedupe within globals (e.g. Session in both site.global AND site.pageSections)
|
|
167
|
+
}
|
|
168
|
+
result.push(s);
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Loader wrapper
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
type AnyLoader = (...args: any[]) => Promise<any>;
|
|
178
|
+
|
|
179
|
+
function wrapLoader<L extends AnyLoader>(loader: L): L {
|
|
180
|
+
const wrapped: AnyLoader = async (...args: Parameters<L>) => {
|
|
181
|
+
const [page, globals] = await Promise.all([loader(...args), resolveSiteGlobals()]);
|
|
182
|
+
if (!page) return page;
|
|
183
|
+
|
|
184
|
+
const existing: ResolvedSection[] =
|
|
185
|
+
(page as { resolvedSections?: ResolvedSection[] }).resolvedSections ?? [];
|
|
186
|
+
const merged = [...dedupeGlobals(globals.resolvedSections, existing), ...existing];
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
...page,
|
|
190
|
+
resolvedSections: merged,
|
|
191
|
+
siteGlobals: { rawRefs: globals.rawRefs } satisfies SiteGlobalsLoaderData,
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
return wrapped as L;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Public wrapper API
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Wrap a route config (from `cmsRouteConfig` or `cmsHomeRouteConfig`) so
|
|
203
|
+
* that its loader merges site globals into `resolvedSections` and exposes
|
|
204
|
+
* the raw site-block refs as `loaderData.siteGlobals.rawRefs`.
|
|
205
|
+
*
|
|
206
|
+
* Sites that don't declare `site.theme/site.global/site.pageSections` in
|
|
207
|
+
* the CMS see no behavior change (the wrapper short-circuits).
|
|
208
|
+
*
|
|
209
|
+
* Ordering: globals render BEFORE page sections (theme injects CSS first,
|
|
210
|
+
* fixed-position helpers mount as asides, etc.). Within globals, ordering
|
|
211
|
+
* is `theme → global → pageSections`.
|
|
212
|
+
*/
|
|
213
|
+
export function withSiteGlobals<T extends { loader: AnyLoader }>(routeConfig: T): T {
|
|
214
|
+
return {
|
|
215
|
+
...routeConfig,
|
|
216
|
+
loader: wrapLoader(routeConfig.loader),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Test-only resets (not exported in public types — used by withSiteGlobals.test.ts)
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
/** @internal */
|
|
225
|
+
export function __resetSiteGlobalsCache() {
|
|
226
|
+
cache = null;
|
|
227
|
+
inflight = null;
|
|
228
|
+
}
|
package/src/sdk/index.ts
CHANGED
|
@@ -11,12 +11,12 @@ export {
|
|
|
11
11
|
type CacheProfileName,
|
|
12
12
|
type CacheProfileOverrides,
|
|
13
13
|
type CacheTimingWindow,
|
|
14
|
-
type EdgeCacheConfig,
|
|
15
|
-
type LoaderCacheOptions,
|
|
16
14
|
cacheHeaders,
|
|
17
15
|
detectCacheProfile,
|
|
16
|
+
type EdgeCacheConfig,
|
|
18
17
|
edgeCacheConfig,
|
|
19
18
|
getCacheProfile,
|
|
19
|
+
type LoaderCacheOptions,
|
|
20
20
|
loaderCacheOptions,
|
|
21
21
|
registerCachePattern,
|
|
22
22
|
routeCacheDefaults,
|
|
@@ -24,15 +24,26 @@ export {
|
|
|
24
24
|
} from "./cacheHeaders";
|
|
25
25
|
export { clx } from "./clx";
|
|
26
26
|
export { decodeCookie, deleteCookie, getCookie, getServerSideCookie, setCookie } from "./cookie";
|
|
27
|
+
export { forwardResponseCookies, getRequestCookieHeader } from "./cookiePassthrough";
|
|
27
28
|
export { buildCSPHeaderValue, type CSPOptions, setCSPHeaders } from "./csp";
|
|
29
|
+
export { djb2, djb2Hex } from "./djb2";
|
|
28
30
|
export { isDevMode } from "./env";
|
|
31
|
+
export { buildHtmlShell, type HtmlShellOptions } from "./htmlShell";
|
|
29
32
|
export {
|
|
30
33
|
createInstrumentedFetch,
|
|
31
34
|
type FetchInstrumentationOptions,
|
|
32
35
|
type FetchMetrics,
|
|
33
36
|
instrumentFetch,
|
|
34
37
|
} from "./instrumentedFetch";
|
|
35
|
-
export {
|
|
38
|
+
export {
|
|
39
|
+
batchInvoke,
|
|
40
|
+
createAppInvoke,
|
|
41
|
+
createInvokeProxy,
|
|
42
|
+
type InvokeProxy,
|
|
43
|
+
invoke,
|
|
44
|
+
invokeQueryOptions,
|
|
45
|
+
type NestedFromFlat,
|
|
46
|
+
} from "./invoke";
|
|
36
47
|
export { createCacheControlCollector, mergeCacheControl } from "./mergeCacheControl";
|
|
37
48
|
export {
|
|
38
49
|
getProductionOrigins,
|
|
@@ -59,8 +70,6 @@ export {
|
|
|
59
70
|
registerTrackingParams,
|
|
60
71
|
stripTrackingParams,
|
|
61
72
|
} from "./urlUtils";
|
|
62
|
-
export { djb2, djb2Hex } from "./djb2";
|
|
63
|
-
export { buildHtmlShell, type HtmlShellOptions } from "./htmlShell";
|
|
64
73
|
export {
|
|
65
74
|
checkDesktop,
|
|
66
75
|
checkMobile,
|
|
@@ -74,9 +83,14 @@ export {
|
|
|
74
83
|
} from "./useDevice";
|
|
75
84
|
export { useHydrated } from "./useHydrated";
|
|
76
85
|
export { useId } from "./useId";
|
|
77
|
-
export {
|
|
86
|
+
export {
|
|
87
|
+
inlineScript,
|
|
88
|
+
usePartialSection,
|
|
89
|
+
useScript,
|
|
90
|
+
useScriptAsDataURI,
|
|
91
|
+
useSection,
|
|
92
|
+
} from "./useScript";
|
|
78
93
|
export { createDecoWorkerEntry, type DecoWorkerEntryOptions } from "./workerEntry";
|
|
79
|
-
export { forwardResponseCookies, getRequestCookieHeader } from "./cookiePassthrough";
|
|
80
94
|
export {
|
|
81
95
|
isWrappedError,
|
|
82
96
|
unwrapError,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createAppInvoke, invoke } from "./invoke";
|
|
3
|
+
|
|
4
|
+
describe("createAppInvoke", () => {
|
|
5
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
fetchMock = vi.fn();
|
|
9
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.unstubAllGlobals();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("collects nested property access into slash-separated key", async () => {
|
|
17
|
+
fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
18
|
+
|
|
19
|
+
const proxy = createAppInvoke();
|
|
20
|
+
const result = await proxy.vtex.actions.checkout.addItemsToCart({ qty: 1 });
|
|
21
|
+
|
|
22
|
+
expect(result).toEqual({ ok: true });
|
|
23
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
24
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
25
|
+
expect(url).toBe("/deco/invoke/vtex/actions/checkout/addItemsToCart");
|
|
26
|
+
expect(init.method).toBe("POST");
|
|
27
|
+
expect(init.headers).toEqual({ "Content-Type": "application/json" });
|
|
28
|
+
expect(JSON.parse(init.body)).toEqual({ qty: 1 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("falls back to .ts suffix when first key returns 404", async () => {
|
|
32
|
+
fetchMock
|
|
33
|
+
.mockResolvedValueOnce(new Response(null, { status: 404 }))
|
|
34
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({ via: "ts" }), { status: 200 }));
|
|
35
|
+
|
|
36
|
+
const proxy = createAppInvoke();
|
|
37
|
+
const result = await proxy.site.loaders.Wishlist({});
|
|
38
|
+
|
|
39
|
+
expect(result).toEqual({ via: "ts" });
|
|
40
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
41
|
+
expect(fetchMock.mock.calls[0][0]).toBe("/deco/invoke/site/loaders/Wishlist");
|
|
42
|
+
expect(fetchMock.mock.calls[1][0]).toBe("/deco/invoke/site/loaders/Wishlist.ts");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("throws 'handler not found' when both key and .ts variant 404", async () => {
|
|
46
|
+
fetchMock.mockResolvedValue(new Response(null, { status: 404 }));
|
|
47
|
+
|
|
48
|
+
const proxy = createAppInvoke();
|
|
49
|
+
await expect(proxy.unknown({})).rejects.toThrow(/invoke\(unknown\) failed: handler not found/);
|
|
50
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("throws with status and error body on non-OK non-404 response", async () => {
|
|
54
|
+
fetchMock.mockResolvedValue(new Response(JSON.stringify({ error: "boom" }), { status: 500 }));
|
|
55
|
+
|
|
56
|
+
const proxy = createAppInvoke();
|
|
57
|
+
await expect(proxy.broken({})).rejects.toThrow(/invoke\(broken\) failed \(500\): boom/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("defaults to empty object body when called with no props", async () => {
|
|
61
|
+
fetchMock.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
62
|
+
|
|
63
|
+
const proxy = createAppInvoke();
|
|
64
|
+
await proxy.foo.bar();
|
|
65
|
+
|
|
66
|
+
const init = fetchMock.mock.calls[0][1];
|
|
67
|
+
expect(JSON.parse(init.body)).toEqual({});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("traps `then`, `catch`, `finally` to avoid being awaited as a thenable", () => {
|
|
71
|
+
const proxy = createAppInvoke();
|
|
72
|
+
expect(proxy.then).toBeUndefined();
|
|
73
|
+
expect(proxy.catch).toBeUndefined();
|
|
74
|
+
expect(proxy.finally).toBeUndefined();
|
|
75
|
+
// And on a deeper path:
|
|
76
|
+
expect(proxy.foo.bar.then).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns undefined for symbol property access (no spurious sub-proxy)", () => {
|
|
80
|
+
const proxy = createAppInvoke();
|
|
81
|
+
expect(proxy[Symbol.toPrimitive]).toBeUndefined();
|
|
82
|
+
expect(proxy[Symbol.iterator]).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("respects custom basePath", async () => {
|
|
86
|
+
fetchMock.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
|
|
87
|
+
|
|
88
|
+
const proxy = createAppInvoke("/custom/invoke");
|
|
89
|
+
await proxy.foo.bar({});
|
|
90
|
+
|
|
91
|
+
expect(fetchMock.mock.calls[0][0]).toBe("/custom/invoke/foo/bar");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("default invoke singleton", () => {
|
|
96
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
fetchMock = vi.fn();
|
|
100
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
vi.unstubAllGlobals();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("is a usable nested proxy bound to /deco/invoke", async () => {
|
|
108
|
+
fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
|
109
|
+
|
|
110
|
+
await (invoke as any).site.loaders.example({ x: 1 });
|
|
111
|
+
|
|
112
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(fetchMock.mock.calls[0][0]).toBe("/deco/invoke/site/loaders/example");
|
|
114
|
+
});
|
|
115
|
+
});
|
package/src/sdk/invoke.ts
CHANGED
|
@@ -154,29 +154,21 @@ type SplitFirst<S extends string> = S extends `${infer Head}/${infer Tail}`
|
|
|
154
154
|
? [Head, Tail]
|
|
155
155
|
: [S, never];
|
|
156
156
|
|
|
157
|
-
type BuildNested<Key extends string, Value> =
|
|
158
|
-
infer H extends string,
|
|
159
|
-
|
|
160
|
-
]
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
: { [K in H]: Value }
|
|
164
|
-
: never;
|
|
157
|
+
type BuildNested<Key extends string, Value> =
|
|
158
|
+
SplitFirst<Key> extends [infer H extends string, infer T]
|
|
159
|
+
? T extends string
|
|
160
|
+
? { [K in H]: BuildNested<T, Value> }
|
|
161
|
+
: { [K in H]: Value }
|
|
162
|
+
: never;
|
|
165
163
|
|
|
166
|
-
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
|
|
167
|
-
k: infer I,
|
|
168
|
-
) => void
|
|
164
|
+
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
|
|
169
165
|
? I
|
|
170
166
|
: never;
|
|
171
167
|
|
|
172
|
-
type DeepMerge<T> = T extends object
|
|
173
|
-
? { [K in keyof T]: DeepMerge<T[K]> }
|
|
174
|
-
: T;
|
|
168
|
+
type DeepMerge<T> = T extends object ? { [K in keyof T]: DeepMerge<T[K]> } : T;
|
|
175
169
|
|
|
176
170
|
export type NestedFromFlat<T extends Record<string, any>> = DeepMerge<
|
|
177
|
-
UnionToIntersection<
|
|
178
|
-
{ [K in keyof T & string]: BuildNested<K, T[K]> }[keyof T & string]
|
|
179
|
-
>
|
|
171
|
+
UnionToIntersection<{ [K in keyof T & string]: BuildNested<K, T[K]> }[keyof T & string]>
|
|
180
172
|
>;
|
|
181
173
|
|
|
182
174
|
/**
|
|
@@ -203,10 +195,10 @@ export type NestedFromFlat<T extends Record<string, any>> = DeepMerge<
|
|
|
203
195
|
* ```
|
|
204
196
|
*/
|
|
205
197
|
export function createAppInvoke(basePath?: string): any;
|
|
206
|
-
export function createAppInvoke<T extends Record<string, any>>(
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
): any {
|
|
198
|
+
export function createAppInvoke<T extends Record<string, any>>(
|
|
199
|
+
basePath?: string,
|
|
200
|
+
): NestedFromFlat<T>;
|
|
201
|
+
export function createAppInvoke(basePath = "/deco/invoke"): any {
|
|
210
202
|
function buildProxy(path: string[]): any {
|
|
211
203
|
return new Proxy(
|
|
212
204
|
Object.assign(async (props: any) => {
|
|
@@ -217,7 +209,10 @@ export function createAppInvoke(
|
|
|
217
209
|
headers: { "Content-Type": "application/json" },
|
|
218
210
|
body: JSON.stringify(props ?? {}),
|
|
219
211
|
});
|
|
220
|
-
if (response.status === 404) {
|
|
212
|
+
if (response.status === 404) {
|
|
213
|
+
await response.body?.cancel();
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
221
216
|
if (!response.ok) {
|
|
222
217
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
223
218
|
throw new Error(
|
|
@@ -242,3 +237,24 @@ export function createAppInvoke(
|
|
|
242
237
|
|
|
243
238
|
return buildProxy([]);
|
|
244
239
|
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Default nested invoke proxy bound to `/deco/invoke`.
|
|
243
|
+
*
|
|
244
|
+
* Replaces site-level `~/runtime.ts` shims that wrap the same `createAppInvoke`
|
|
245
|
+
* call. Importing this singleton means no per-site boilerplate.
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```ts
|
|
249
|
+
* import { invoke } from "@decocms/start/sdk/invoke";
|
|
250
|
+
*
|
|
251
|
+
* await invoke.vtex.actions.checkout.addItemsToCart({ orderFormId, orderItems });
|
|
252
|
+
* await invoke.site.loaders.Wishlist.getWishlist({});
|
|
253
|
+
* ```
|
|
254
|
+
*
|
|
255
|
+
* For a custom `basePath` or typed handlers, call `createAppInvoke()` directly:
|
|
256
|
+
* ```ts
|
|
257
|
+
* const invoke = createAppInvoke<Handlers>("/my/invoke");
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
export const invoke = createAppInvoke();
|