@decocms/start 2.1.3 → 2.3.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": "2.1.3",
3
+ "version": "2.3.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,14 @@ import path from "node:path";
19
19
  * --out Output file (default: "src/server/admin/meta.gen.json")
20
20
  * --platform Platform name (default: "cloudflare")
21
21
  */
22
- import { type Symbol as MorphSymbol, Node, Project, SyntaxKind, type Type } from "ts-morph";
22
+ import {
23
+ type Symbol as MorphSymbol,
24
+ Node,
25
+ Project,
26
+ type SourceFile,
27
+ SyntaxKind,
28
+ type Type,
29
+ } from "ts-morph";
23
30
 
24
31
  // ---------------------------------------------------------------------------
25
32
  // CLI arg parsing
@@ -448,6 +455,39 @@ function resolveModulePath(
448
455
  return fs.existsSync(target) ? target : null;
449
456
  }
450
457
 
458
+ type SourceFileCache = Map<string, SourceFile>;
459
+ type ModuleResolutionCache = Map<string, string | null>;
460
+ type PropsSchemaCache = Map<string, any>;
461
+
462
+ function getSourceFile(
463
+ project: import("ts-morph").Project,
464
+ filePath: string,
465
+ cache: SourceFileCache,
466
+ ): SourceFile {
467
+ const normalizedPath = path.resolve(filePath);
468
+ const cached = cache.get(normalizedPath);
469
+ if (cached) return cached;
470
+
471
+ const sourceFile =
472
+ project.getSourceFile(normalizedPath) ?? project.addSourceFileAtPath(normalizedPath);
473
+ cache.set(normalizedPath, sourceFile);
474
+ return sourceFile;
475
+ }
476
+
477
+ function resolveModulePathCached(
478
+ moduleSpec: string,
479
+ fromFile: string,
480
+ projectRoot: string,
481
+ cache: ModuleResolutionCache,
482
+ ): string | null {
483
+ const key = `${fromFile}\0${moduleSpec}`;
484
+ if (cache.has(key)) return cache.get(key) ?? null;
485
+
486
+ const resolved = resolveModulePath(moduleSpec, fromFile, projectRoot);
487
+ cache.set(key, resolved);
488
+ return resolved;
489
+ }
490
+
451
491
  /**
452
492
  * Recursively follow `export { default } from "..."` chains (up to maxDepth hops)
453
493
  * and try to extract Props from each target file.
@@ -458,6 +498,9 @@ function resolvePropsViaReExport(
458
498
  filePath: string,
459
499
  projectRoot: string,
460
500
  maxDepth: number,
501
+ sourceFileCache: SourceFileCache,
502
+ moduleResolutionCache: ModuleResolutionCache,
503
+ propsSchemaCache: PropsSchemaCache,
461
504
  ): any | null {
462
505
  if (maxDepth <= 0) return null;
463
506
 
@@ -471,21 +514,41 @@ function resolvePropsViaReExport(
471
514
  });
472
515
  if (!hasDefault) continue;
473
516
 
474
- const targetPath = resolveModulePath(moduleSpec, filePath, projectRoot);
517
+ const targetPath = resolveModulePathCached(
518
+ moduleSpec,
519
+ filePath,
520
+ projectRoot,
521
+ moduleResolutionCache,
522
+ );
475
523
  if (!targetPath) continue;
476
524
 
525
+ const cachedProps = propsSchemaCache.get(targetPath);
526
+ if (cachedProps) return cachedProps;
527
+
477
528
  try {
478
- const targetFile = project.addSourceFileAtPath(targetPath);
529
+ const targetFile = getSourceFile(project, targetPath, sourceFileCache);
479
530
 
480
531
  const targetProps = targetFile.getInterface("Props");
481
- if (targetProps) return typeToJsonSchema(targetProps.getType());
532
+ if (targetProps) {
533
+ const schema = typeToJsonSchema(targetProps.getType());
534
+ propsSchemaCache.set(targetPath, schema);
535
+ return schema;
536
+ }
482
537
 
483
538
  const targetAlias = targetFile.getTypeAlias("Props");
484
- if (targetAlias) return typeToJsonSchema(targetAlias.getType());
539
+ if (targetAlias) {
540
+ const schema = typeToJsonSchema(targetAlias.getType());
541
+ propsSchemaCache.set(targetPath, schema);
542
+ return schema;
543
+ }
485
544
 
486
545
  // Type-checker approach: extract from default export call signature
487
546
  const propsType = extractDefaultExportPropsType(targetFile);
488
- if (propsType) return typeToJsonSchema(propsType);
547
+ if (propsType) {
548
+ const schema = typeToJsonSchema(propsType);
549
+ propsSchemaCache.set(targetPath, schema);
550
+ return schema;
551
+ }
489
552
 
490
553
  // Recurse: target might also re-export from another file
491
554
  const deeper = resolvePropsViaReExport(
@@ -494,8 +557,14 @@ function resolvePropsViaReExport(
494
557
  targetPath,
495
558
  projectRoot,
496
559
  maxDepth - 1,
560
+ sourceFileCache,
561
+ moduleResolutionCache,
562
+ propsSchemaCache,
497
563
  );
498
- if (deeper) return deeper;
564
+ if (deeper) {
565
+ propsSchemaCache.set(targetPath, deeper);
566
+ return deeper;
567
+ }
499
568
  } catch {
500
569
  // Target file couldn't be parsed
501
570
  }
@@ -530,6 +599,9 @@ function generateMeta(): MetaResponse {
530
599
  const definitions: Record<string, any> = {};
531
600
  const sectionBlocks: Record<string, any> = {};
532
601
  const sectionRootAnyOf: any[] = [];
602
+ const sourceFileCache: SourceFileCache = new Map();
603
+ const moduleResolutionCache: ModuleResolutionCache = new Map();
604
+ const propsSchemaCache: PropsSchemaCache = new Map();
533
605
 
534
606
  // Resolvable: the admin's deRefUntil expects the LITERAL key "Resolvable",
535
607
  // not a base64-encoded version. We store both for compatibility.
@@ -553,13 +625,16 @@ function generateMeta(): MetaResponse {
553
625
 
554
626
  const sectionFiles = findTsxFiles(sectionsDir);
555
627
  console.log(`Found ${sectionFiles.length} section files`);
628
+ for (const filePath of sectionFiles) {
629
+ getSourceFile(project, filePath, sourceFileCache);
630
+ }
556
631
 
557
632
  for (const filePath of sectionFiles) {
558
633
  const relativePath = path.relative(srcDir, filePath);
559
634
  const blockKey = `${SITE_NAMESPACE}/${relativePath}`;
560
635
 
561
636
  try {
562
- const sourceFile = project.addSourceFileAtPath(filePath);
637
+ const sourceFile = getSourceFile(project, filePath, sourceFileCache);
563
638
 
564
639
  let propsSchema: any = null;
565
640
 
@@ -573,7 +648,16 @@ function generateMeta(): MetaResponse {
573
648
  // Strategy 2: Follow re-exports recursively (up to 3 hops)
574
649
  // Handles: section → island → component chains
575
650
  if (!propsSchema) {
576
- propsSchema = resolvePropsViaReExport(project, sourceFile, filePath, root, 3);
651
+ propsSchema = resolvePropsViaReExport(
652
+ project,
653
+ sourceFile,
654
+ filePath,
655
+ root,
656
+ 3,
657
+ sourceFileCache,
658
+ moduleResolutionCache,
659
+ propsSchemaCache,
660
+ );
577
661
  }
578
662
 
579
663
  // Strategy 4: Default export call signature in the section file via type checker
@@ -46,10 +46,36 @@ const IMPORT_RULES: Array<[RegExp, string | null]> = [
46
46
  [/^"apps\/vtex\/hooks\/useWishlist(?:\.ts)?"$/, `"~/hooks/useWishlist"`],
47
47
  [/^"apps\/vtex\/hooks\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/hooks/$1"`],
48
48
  // Specific VTEX utils that moved to different paths in @decocms/apps
49
- [/^"apps\/vtex\/utils\/fetchVTEX(?:\.ts)?"$/, `"@decocms/apps/vtex/client"`],
49
+ // fetchVTEX (generic fetchSafe + QS sanitization) lives at vtex/utils/fetch in apps-start.
50
+ [/^"apps\/vtex\/utils\/fetchVTEX(?:\.ts)?"$/, `"@decocms/apps/vtex/utils/fetch"`],
50
51
  [/^"apps\/vtex\/utils\/client(?:\.ts)?"$/, `"@decocms/apps/vtex/client"`],
51
52
  [/^"apps\/vtex\/utils\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/utils/$1"`],
52
53
  [/^"apps\/vtex\/actions\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/actions/$1"`],
54
+ // Tier B loader path rewrites (apps-start has no `intelligentSearch/`, `legacy/<file>`, or `paths/` subdirs).
55
+ // Intelligent Search loaders moved to inline-loaders/.
56
+ [
57
+ /^"apps\/vtex\/loaders\/intelligentSearch\/productList(?:\.ts)?"$/,
58
+ `"@decocms/apps/vtex/inline-loaders/productList"`,
59
+ ],
60
+ [
61
+ /^"apps\/vtex\/loaders\/intelligentSearch\/productListingPage(?:\.ts)?"$/,
62
+ `"@decocms/apps/vtex/inline-loaders/productListingPage"`,
63
+ ],
64
+ [
65
+ /^"apps\/vtex\/loaders\/intelligentSearch\/productDetailsPage(?:\.ts)?"$/,
66
+ `"@decocms/apps/vtex/inline-loaders/productDetailsPage"`,
67
+ ],
68
+ [
69
+ /^"apps\/vtex\/loaders\/intelligentSearch\/suggestions(?:\.ts)?"$/,
70
+ `"@decocms/apps/vtex/inline-loaders/suggestions"`,
71
+ ],
72
+ // Legacy product loaders are consolidated into a single file (named exports).
73
+ [
74
+ /^"apps\/vtex\/loaders\/legacy\/(?:productList|productListingPage|productDetailsPage|search|category)(?:\.ts)?"$/,
75
+ `"@decocms/apps/vtex/loaders/legacy"`,
76
+ ],
77
+ // Path-default loaders (sitemap seeds) don't exist in TanStack Start — paths resolve at request time.
78
+ [/^"apps\/vtex\/loaders\/paths\/(?:[^"]+)(?:\.ts)?"$/, null],
53
79
  [/^"apps\/vtex\/loaders\/([^"]+?)(?:\.ts)?"$/, `"@decocms/apps/vtex/loaders/$1"`],
54
80
  [/^"apps\/vtex\/types(?:\.ts)?"$/, `"@decocms/apps/vtex/types"`],
55
81
  [/^"apps\/vtex\/mod(?:\.ts)?"$/, `"~/types/vtex-app"`],
@@ -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 type { PageSeo } from "../cms/resolve";
19
- export type { Device } from "../sdk/useDevice";
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
+ }