@decocms/blocks 7.0.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.
Files changed (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,993 @@
1
+ /**
2
+ * Framework-level schema definitions and runtime meta composition.
3
+ *
4
+ * The schema generator (scripts/generate-schema.ts) produces section and app
5
+ * schemas from site TypeScript files. Framework-managed block types -- pages,
6
+ * loaders, matchers, flags -- have their schemas defined here and injected
7
+ * at runtime via composeMeta().
8
+ *
9
+ * This keeps the generator focused on site-specific concerns while the
10
+ * framework owns the schemas for its own block types.
11
+ */
12
+
13
+ export interface MetaResponse {
14
+ major: number;
15
+ version: string;
16
+ namespace: string;
17
+ site: string;
18
+ manifest: {
19
+ blocks: Record<string, Record<string, { $ref: string; namespace?: string }>>;
20
+ };
21
+ schema: {
22
+ definitions: Record<string, any>;
23
+ root: Record<string, any>;
24
+ };
25
+ platform?: string;
26
+ cloudProvider?: string;
27
+ framework?: string;
28
+ etag?: string;
29
+ }
30
+
31
+ /**
32
+ * Standard base64 encoding that matches the browser's btoa().
33
+ * The admin uses btoa(resolveType) in some code paths to construct
34
+ * definition refs, so our keys MUST include the = padding.
35
+ */
36
+ function toBase64(str: string): string {
37
+ if (typeof btoa === "function") return btoa(str);
38
+ return Buffer.from(str).toString("base64");
39
+ }
40
+
41
+ // The admin's deRefUntil and ArrayFieldTemplate look for the LITERAL
42
+ // string "Resolvable" (not base64-encoded). Both keys are needed:
43
+ // - literal for admin detection
44
+ // - base64 for backward compat with any code that does btoa("Resolvable")
45
+ const RESOLVABLE_LITERAL_KEY = "Resolvable";
46
+ const RESOLVABLE_B64_KEY = toBase64("Resolvable");
47
+
48
+ function buildResolvableDefinition() {
49
+ return {
50
+ title: "Select from saved",
51
+ type: "object",
52
+ required: ["__resolveType"],
53
+ additionalProperties: true,
54
+ properties: { __resolveType: { type: "string" } },
55
+ };
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Loader definitions — dynamic registry
60
+ // ---------------------------------------------------------------------------
61
+
62
+ export interface LoaderConfig {
63
+ key: string;
64
+ title: string;
65
+ namespace: string;
66
+ description?: string;
67
+ icon?: string;
68
+ propsSchema: Record<string, any>;
69
+ /** Tags for property matching (e.g., "product-list" enables injection into Product[] props). */
70
+ tags?: string[];
71
+ }
72
+
73
+ const loaderRegistry: LoaderConfig[] = [];
74
+
75
+ /** Register a single loader schema for the admin. */
76
+ export function registerLoaderSchema(config: LoaderConfig) {
77
+ const idx = loaderRegistry.findIndex((l) => l.key === config.key);
78
+ if (idx >= 0) {
79
+ loaderRegistry[idx] = config;
80
+ } else {
81
+ loaderRegistry.push(config);
82
+ }
83
+ }
84
+
85
+ /** Register multiple loader schemas at once. */
86
+ export function registerLoaderSchemas(configs: LoaderConfig[]) {
87
+ for (const config of configs) registerLoaderSchema(config);
88
+ }
89
+
90
+ /** Get all registered loader schemas. */
91
+ export function getRegisteredLoaders(): LoaderConfig[] {
92
+ return [...loaderRegistry];
93
+ }
94
+
95
+ function getProductListLoaderKeys(): string[] {
96
+ return loaderRegistry.filter((l) => l.tags?.includes("product-list")).map((l) => l.key);
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Action definitions — dynamic registry
101
+ // ---------------------------------------------------------------------------
102
+
103
+ export interface ActionConfig {
104
+ key: string;
105
+ title: string;
106
+ namespace: string;
107
+ description?: string;
108
+ icon?: string;
109
+ propsSchema: Record<string, any>;
110
+ }
111
+
112
+ const actionRegistry: ActionConfig[] = [];
113
+
114
+ /** Register a single action schema for the admin. */
115
+ export function registerActionSchema(config: ActionConfig) {
116
+ const idx = actionRegistry.findIndex((a) => a.key === config.key);
117
+ if (idx >= 0) {
118
+ actionRegistry[idx] = config;
119
+ } else {
120
+ actionRegistry.push(config);
121
+ }
122
+ }
123
+
124
+ /** Register multiple action schemas at once. */
125
+ export function registerActionSchemas(configs: ActionConfig[]) {
126
+ for (const config of configs) registerActionSchema(config);
127
+ }
128
+
129
+ function buildActionDefinitions() {
130
+ const definitions: Record<string, any> = {};
131
+ const manifestBlocks: Record<string, any> = {};
132
+
133
+ for (const action of actionRegistry) {
134
+ const defKey = toBase64(action.key);
135
+
136
+ definitions[defKey] = {
137
+ title: action.key,
138
+ ...(action.description ? { description: action.description } : {}),
139
+ ...(action.icon ? { icon: action.icon } : {}),
140
+ type: "object",
141
+ required: ["__resolveType", ...(action.propsSchema?.required || [])],
142
+ properties: {
143
+ __resolveType: {
144
+ type: "string",
145
+ enum: [action.key],
146
+ default: action.key,
147
+ },
148
+ ...(action.propsSchema?.properties || {}),
149
+ },
150
+ };
151
+
152
+ manifestBlocks[action.key] = {
153
+ $ref: `#/definitions/${defKey}`,
154
+ namespace: action.namespace,
155
+ };
156
+ }
157
+
158
+ return { definitions, manifestBlocks };
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Matcher definitions — dynamic registry
163
+ // ---------------------------------------------------------------------------
164
+
165
+ export interface MatcherConfig {
166
+ key: string;
167
+ title: string;
168
+ namespace: string;
169
+ description?: string;
170
+ icon?: string;
171
+ propsSchema?: Record<string, any>;
172
+ }
173
+
174
+ const matcherRegistry: MatcherConfig[] = [];
175
+
176
+ /** Register a single matcher schema for the admin. */
177
+ export function registerMatcherSchema(config: MatcherConfig) {
178
+ const idx = matcherRegistry.findIndex((m) => m.key === config.key);
179
+ if (idx >= 0) {
180
+ matcherRegistry[idx] = config;
181
+ } else {
182
+ matcherRegistry.push(config);
183
+ }
184
+ }
185
+
186
+ /** Register multiple matcher schemas at once. */
187
+ export function registerMatcherSchemas(configs: MatcherConfig[]) {
188
+ for (const config of configs) registerMatcherSchema(config);
189
+ }
190
+
191
+ /** Get all registered matcher schemas. */
192
+ export function getRegisteredMatchers(): MatcherConfig[] {
193
+ return matcherRegistry;
194
+ }
195
+
196
+ // Register built-in matchers that are always available
197
+ registerMatcherSchemas([
198
+ {
199
+ key: "website/matchers/always.ts",
200
+ title: "Always",
201
+ description: "Target all users",
202
+ icon: "eye",
203
+ namespace: "website",
204
+ },
205
+ {
206
+ key: "website/matchers/never.ts",
207
+ title: "Never",
208
+ description: "Hide from all users",
209
+ icon: "eye-off",
210
+ namespace: "website",
211
+ },
212
+ {
213
+ key: "website/matchers/device.ts",
214
+ title: "Device",
215
+ description: "Target users based on their device type, such as desktop, tablet, or mobile",
216
+ icon: "device-mobile",
217
+ namespace: "website",
218
+ propsSchema: {
219
+ type: "object",
220
+ properties: {
221
+ mobile: { type: "boolean", title: "Mobile" },
222
+ desktop: { type: "boolean", title: "Desktop" },
223
+ },
224
+ },
225
+ },
226
+ {
227
+ key: "website/matchers/date.ts",
228
+ title: "Date and Time",
229
+ description: "Target users based on specific dates or date ranges, including specific times",
230
+ icon: "calendar-event",
231
+ namespace: "website",
232
+ propsSchema: {
233
+ type: "object",
234
+ properties: {
235
+ start: { type: "string", title: "Start Date", format: "date-time" },
236
+ end: { type: "string", title: "End Date", format: "date-time" },
237
+ },
238
+ },
239
+ },
240
+ {
241
+ key: "website/matchers/cron.ts",
242
+ title: "Cron",
243
+ description: "Target users with precision using recurring schedules",
244
+ icon: "refresh",
245
+ namespace: "website",
246
+ propsSchema: {
247
+ type: "object",
248
+ properties: {
249
+ start: { type: "string", title: "Start", format: "date-time" },
250
+ end: { type: "string", title: "End", format: "date-time" },
251
+ },
252
+ },
253
+ },
254
+ {
255
+ key: "website/matchers/cookie.ts",
256
+ title: "Cookie",
257
+ description: "Target users that have a specific cookie",
258
+ icon: "cookie",
259
+ namespace: "website",
260
+ propsSchema: {
261
+ type: "object",
262
+ properties: {
263
+ name: { type: "string", title: "Cookie Name" },
264
+ value: { type: "string", title: "Cookie Value" },
265
+ },
266
+ },
267
+ },
268
+ {
269
+ key: "website/matchers/host.ts",
270
+ title: "Host",
271
+ description: "Target users based on the domain or subdomain they are accessing your site from",
272
+ icon: "world-www",
273
+ namespace: "website",
274
+ propsSchema: {
275
+ type: "object",
276
+ properties: {
277
+ host: { type: "string", title: "Hostname" },
278
+ },
279
+ },
280
+ },
281
+ {
282
+ key: "website/matchers/pathname.ts",
283
+ title: "Pathname",
284
+ description: "Target users based on the pathname",
285
+ icon: "world-www",
286
+ namespace: "website",
287
+ propsSchema: {
288
+ type: "object",
289
+ properties: {
290
+ pattern: { type: "string", title: "Regex Pattern" },
291
+ includes: {
292
+ type: "array",
293
+ title: "Includes",
294
+ items: { type: "string" },
295
+ },
296
+ excludes: {
297
+ type: "array",
298
+ title: "Excludes",
299
+ items: { type: "string" },
300
+ },
301
+ },
302
+ },
303
+ },
304
+ {
305
+ key: "website/matchers/queryString.ts",
306
+ title: "Query String",
307
+ description: "Match with a specific querystring",
308
+ icon: "question-mark",
309
+ namespace: "website",
310
+ propsSchema: {
311
+ type: "object",
312
+ properties: {
313
+ key: { type: "string", title: "Parameter Name" },
314
+ value: { type: "string", title: "Parameter Value" },
315
+ },
316
+ },
317
+ },
318
+ {
319
+ key: "website/matchers/random.ts",
320
+ title: "Random",
321
+ description: "Target a percentage of the total traffic to do an A/B test",
322
+ icon: "arrow-split",
323
+ namespace: "website",
324
+ propsSchema: {
325
+ type: "object",
326
+ properties: {
327
+ traffic: {
328
+ type: "number",
329
+ title: "Traffic Percentage (0\u20131)",
330
+ minimum: 0,
331
+ maximum: 1,
332
+ },
333
+ },
334
+ },
335
+ },
336
+ {
337
+ key: "website/matchers/location.ts",
338
+ title: "Location",
339
+ description:
340
+ "Target users based on their geographical location, such as country, city, or region",
341
+ icon: "map-2",
342
+ namespace: "website",
343
+ propsSchema: (() => {
344
+ // `includeLocations`/`excludeLocations` are `(Location | Map)[]` in the
345
+ // matcher source. Emit the union as an `anyOf` of two branches so the CMS
346
+ // renders a Location/Map choice instead of merging every field into one
347
+ // form. The Location branch is exactly {city, regionCode, country}; the
348
+ // Map branch carries the `@format: map` coordinate picker.
349
+ const locationBranch = {
350
+ type: "object" as const,
351
+ title: "Location",
352
+ properties: {
353
+ city: {
354
+ type: "string",
355
+ title: "City",
356
+ examples: ["São Paulo"],
357
+ description:
358
+ "Exact city name (case-insensitive) as returned by Cloudflare's cf-ipcity header.",
359
+ },
360
+ regionCode: {
361
+ type: "string",
362
+ title: "Region Code",
363
+ examples: ["SP", "RJ", "MG", "47"],
364
+ description:
365
+ 'Matches Cloudflare\'s cf-region-code header. Usually the ISO 3166-2 subdivision code (SP, RJ, MG, …); for some regions Cloudflare returns numeric codes (e.g. 47). Use the same value cf-region-code returns for your audience — full region names like "São Paulo" are NOT matched.',
366
+ },
367
+ country: {
368
+ type: "string",
369
+ title: "Country",
370
+ examples: ["BR", "Brasil", "US"],
371
+ description:
372
+ "ISO 3166-1 alpha-2 code (BR, US, AR, …) or a full country name (Brasil, United States) — the matcher resolves common aliases.",
373
+ },
374
+ },
375
+ };
376
+ const mapBranch = {
377
+ type: "object" as const,
378
+ title: "Map",
379
+ properties: {
380
+ coordinates: {
381
+ type: "string",
382
+ title: "Area selection",
383
+ format: "map",
384
+ examples: ["-23.5505,-46.6333,5000"],
385
+ description:
386
+ '"latitude,longitude,radius_in_meters" for haversine-radius matching. Set this for the Map mode.',
387
+ },
388
+ },
389
+ };
390
+ // Mustache title so array items summarise their content instead of
391
+ // "Item 1". Absent fields render empty; the label is trimmed. Covers both
392
+ // branches — Location fields and the Map coordinates.
393
+ const locationOrMapItem = {
394
+ title: "{{{city}}} {{{regionCode}}} {{{country}}} {{{coordinates}}}",
395
+ anyOf: [locationBranch, mapBranch],
396
+ };
397
+ return {
398
+ type: "object" as const,
399
+ properties: {
400
+ includeLocations: {
401
+ type: "array",
402
+ title: "Include Locations",
403
+ items: locationOrMapItem,
404
+ },
405
+ excludeLocations: {
406
+ type: "array",
407
+ title: "Exclude Locations",
408
+ items: locationOrMapItem,
409
+ },
410
+ },
411
+ };
412
+ })(),
413
+ },
414
+ {
415
+ key: "website/matchers/userAgent.ts",
416
+ title: "User Agent",
417
+ description: "Target users based on their web browser or operational system",
418
+ icon: "world",
419
+ namespace: "website",
420
+ propsSchema: {
421
+ type: "object",
422
+ properties: {
423
+ includes: { type: "string", title: "Includes (substring)" },
424
+ match: { type: "string", title: "Match (regex)" },
425
+ },
426
+ },
427
+ },
428
+ {
429
+ key: "website/matchers/environment.ts",
430
+ title: "Environment",
431
+ description:
432
+ "Target users based from where they are accessing your site (development, testing, or production)",
433
+ icon: "code",
434
+ namespace: "website",
435
+ propsSchema: {
436
+ type: "object",
437
+ properties: {
438
+ environment: {
439
+ type: "string",
440
+ title: "Environment",
441
+ enum: ["production", "development"],
442
+ },
443
+ },
444
+ },
445
+ },
446
+ {
447
+ key: "website/matchers/multi.ts",
448
+ title: "Multi",
449
+ description: "Create more complex conditions by combining multiple matchers",
450
+ icon: "plus",
451
+ namespace: "website",
452
+ propsSchema: {
453
+ type: "object",
454
+ properties: {
455
+ op: {
456
+ type: "string",
457
+ title: "Operator",
458
+ enum: ["and", "or"],
459
+ default: "and",
460
+ },
461
+ matchers: {
462
+ type: "array",
463
+ title: "Matchers",
464
+ items: {
465
+ type: "object",
466
+ required: ["__resolveType"],
467
+ properties: { __resolveType: { type: "string" } },
468
+ additionalProperties: true,
469
+ },
470
+ },
471
+ },
472
+ },
473
+ },
474
+ {
475
+ key: "website/matchers/negate.ts",
476
+ title: "Negates",
477
+ description: "Create conditions that target users who do not meet certain criteria",
478
+ icon: "minus",
479
+ namespace: "website",
480
+ propsSchema: {
481
+ type: "object",
482
+ properties: {
483
+ matcher: {
484
+ type: "object",
485
+ title: "Matcher",
486
+ required: ["__resolveType"],
487
+ properties: { __resolveType: { type: "string" } },
488
+ additionalProperties: true,
489
+ },
490
+ },
491
+ },
492
+ },
493
+ ]);
494
+
495
+ function buildLoaderDefinitions() {
496
+ const definitions: Record<string, any> = {};
497
+ const manifestBlocks: Record<string, any> = {};
498
+ const loaderAnyOf: any[] = [{ $ref: `#/definitions/${RESOLVABLE_LITERAL_KEY}` }];
499
+
500
+ for (const loader of loaderRegistry) {
501
+ const defKey = toBase64(loader.key);
502
+
503
+ definitions[defKey] = {
504
+ title: loader.key,
505
+ ...(loader.description ? { description: loader.description } : {}),
506
+ ...(loader.icon ? { icon: loader.icon } : {}),
507
+ type: "object",
508
+ required: ["__resolveType", ...(loader.propsSchema?.required || [])],
509
+ properties: {
510
+ __resolveType: {
511
+ type: "string",
512
+ enum: [loader.key],
513
+ default: loader.key,
514
+ },
515
+ ...(loader.propsSchema?.properties || {}),
516
+ },
517
+ };
518
+
519
+ manifestBlocks[loader.key] = {
520
+ $ref: `#/definitions/${defKey}`,
521
+ namespace: loader.namespace,
522
+ };
523
+
524
+ loaderAnyOf.push({ $ref: `#/definitions/${defKey}` });
525
+ }
526
+
527
+ return { definitions, manifestBlocks, loaderAnyOf };
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // Matcher definitions
532
+ // ---------------------------------------------------------------------------
533
+
534
+ function buildMatcherDefinitions() {
535
+ const definitions: Record<string, any> = {};
536
+ const manifestBlocks: Record<string, any> = {};
537
+ const matcherAnyOf: any[] = [{ $ref: `#/definitions/${RESOLVABLE_LITERAL_KEY}` }];
538
+
539
+ for (const matcher of matcherRegistry) {
540
+ const defKey = toBase64(matcher.key);
541
+ definitions[defKey] = {
542
+ title: matcher.title,
543
+ ...(matcher.description ? { description: matcher.description } : {}),
544
+ ...(matcher.icon ? { icon: matcher.icon } : {}),
545
+ type: "object",
546
+ required: ["__resolveType"],
547
+ properties: {
548
+ __resolveType: {
549
+ type: "string",
550
+ enum: [matcher.key],
551
+ default: matcher.key,
552
+ },
553
+ ...(matcher.propsSchema?.properties || {}),
554
+ },
555
+ };
556
+ manifestBlocks[matcher.key] = {
557
+ $ref: `#/definitions/${defKey}`,
558
+ namespace: matcher.namespace,
559
+ };
560
+ matcherAnyOf.push({ $ref: `#/definitions/${defKey}` });
561
+ }
562
+
563
+ return { definitions, manifestBlocks, matcherAnyOf };
564
+ }
565
+
566
+ // ---------------------------------------------------------------------------
567
+ // Multivariate flag schema
568
+ // ---------------------------------------------------------------------------
569
+
570
+ function buildMultivariateFlagSchema(innerSchema: any) {
571
+ return {
572
+ type: "object",
573
+ required: ["__resolveType"],
574
+ properties: {
575
+ __resolveType: {
576
+ type: "string",
577
+ enum: ["website/flags/multivariate.ts", "website/flags/multivariate/section.ts"],
578
+ },
579
+ variants: {
580
+ type: "array",
581
+ title: "Variants",
582
+ items: {
583
+ type: "object",
584
+ properties: {
585
+ rule: {
586
+ title: "Rule",
587
+ type: "object",
588
+ required: ["__resolveType"],
589
+ properties: {
590
+ __resolveType: { type: "string" },
591
+ },
592
+ additionalProperties: true,
593
+ },
594
+ value: innerSchema,
595
+ },
596
+ },
597
+ },
598
+ },
599
+ };
600
+ }
601
+
602
+ // ---------------------------------------------------------------------------
603
+ // Page schema
604
+ // ---------------------------------------------------------------------------
605
+
606
+ function buildPageSchema(sectionAnyOf: any[]) {
607
+ const PAGE_TYPE = "website/pages/Page.tsx";
608
+ const defKey = toBase64(PAGE_TYPE);
609
+
610
+ const sectionsArraySchema = {
611
+ type: "array",
612
+ title: "Sections",
613
+ items: { anyOf: sectionAnyOf },
614
+ };
615
+
616
+ const sectionsMultivariateSchema = buildMultivariateFlagSchema(sectionsArraySchema);
617
+
618
+ const definition = {
619
+ title: PAGE_TYPE,
620
+ type: "object",
621
+ required: ["__resolveType"],
622
+ properties: {
623
+ __resolveType: {
624
+ type: "string",
625
+ enum: [PAGE_TYPE],
626
+ default: PAGE_TYPE,
627
+ },
628
+ name: { type: "string", title: "Name" },
629
+ path: { type: "string", title: "Path" },
630
+ seo: { title: "SEO", anyOf: sectionAnyOf },
631
+ sections: {
632
+ title: "Sections",
633
+ anyOf: [sectionsArraySchema, sectionsMultivariateSchema],
634
+ },
635
+ },
636
+ };
637
+
638
+ return {
639
+ definitions: { [defKey]: definition },
640
+ manifestBlocks: {
641
+ [PAGE_TYPE]: {
642
+ $ref: `#/definitions/${defKey}`,
643
+ namespace: "website",
644
+ },
645
+ },
646
+ rootAnyOf: [
647
+ { $ref: `#/definitions/${RESOLVABLE_LITERAL_KEY}` },
648
+ { $ref: `#/definitions/${defKey}` },
649
+ ],
650
+ };
651
+ }
652
+
653
+ // ---------------------------------------------------------------------------
654
+ // Framework sections
655
+ // ---------------------------------------------------------------------------
656
+
657
+ function buildFrameworkSections(sectionAnyOf: any[]) {
658
+ const definitions: Record<string, any> = {};
659
+ const manifestBlocks: Record<string, any> = {};
660
+ const extraAnyOf: any[] = [];
661
+
662
+ // --- website/sections/Rendering/Lazy.tsx ---
663
+ const LAZY_TYPE = "website/sections/Rendering/Lazy.tsx";
664
+ const lazyKey = toBase64(LAZY_TYPE);
665
+ definitions[lazyKey] = {
666
+ title: LAZY_TYPE,
667
+ type: "object",
668
+ required: ["__resolveType"],
669
+ properties: {
670
+ __resolveType: {
671
+ type: "string",
672
+ enum: [LAZY_TYPE],
673
+ default: LAZY_TYPE,
674
+ },
675
+ section: {
676
+ title: "Section",
677
+ anyOf: sectionAnyOf,
678
+ },
679
+ },
680
+ };
681
+ manifestBlocks[LAZY_TYPE] = {
682
+ $ref: `#/definitions/${lazyKey}`,
683
+ namespace: "website",
684
+ };
685
+ extraAnyOf.push({ $ref: `#/definitions/${lazyKey}` });
686
+
687
+ // --- website/sections/Seo/Seo.tsx ---
688
+ const SEO_TYPE = "website/sections/Seo/Seo.tsx";
689
+ const seoKey = toBase64(SEO_TYPE);
690
+ definitions[seoKey] = {
691
+ title: SEO_TYPE,
692
+ type: "object",
693
+ required: ["__resolveType"],
694
+ properties: {
695
+ __resolveType: {
696
+ type: "string",
697
+ enum: [SEO_TYPE],
698
+ default: SEO_TYPE,
699
+ },
700
+ title: { type: "string", title: "Title" },
701
+ description: { type: "string", title: "Description" },
702
+ canonical: { type: "string", title: "Canonical URL" },
703
+ favicon: { type: "string", title: "Favicon", format: "image-uri" },
704
+ noIndexing: { type: "boolean", title: "No Indexing" },
705
+ titleTemplate: { type: "string", title: "Title Template" },
706
+ descriptionTemplate: { type: "string", title: "Description Template" },
707
+ type: { type: "string", title: "Page Type" },
708
+ image: { type: "string", title: "OG Image", format: "image-uri" },
709
+ themeColor: { type: "string", title: "Theme Color", format: "color" },
710
+ jsonLDs: {
711
+ type: "array",
712
+ title: "JSON-LD Structured Data",
713
+ items: { type: "object", additionalProperties: true },
714
+ },
715
+ },
716
+ };
717
+ manifestBlocks[SEO_TYPE] = {
718
+ $ref: `#/definitions/${seoKey}`,
719
+ namespace: "website",
720
+ };
721
+ extraAnyOf.push({ $ref: `#/definitions/${seoKey}` });
722
+
723
+ // --- website/sections/Seo/SeoV2.tsx ---
724
+ const SEOV2_TYPE = "website/sections/Seo/SeoV2.tsx";
725
+ const seoV2Key = toBase64(SEOV2_TYPE);
726
+ definitions[seoV2Key] = {
727
+ title: SEOV2_TYPE,
728
+ type: "object",
729
+ required: ["__resolveType"],
730
+ properties: {
731
+ __resolveType: {
732
+ type: "string",
733
+ enum: [SEOV2_TYPE],
734
+ default: SEOV2_TYPE,
735
+ },
736
+ title: { type: "string", title: "Title" },
737
+ description: { type: "string", title: "Description" },
738
+ canonical: { type: "string", title: "Canonical URL" },
739
+ favicon: { type: "string", title: "Favicon", format: "image-uri" },
740
+ noIndexing: { type: "boolean", title: "No Indexing" },
741
+ titleTemplate: { type: "string", title: "Title Template" },
742
+ descriptionTemplate: { type: "string", title: "Description Template" },
743
+ type: { type: "string", title: "Page Type" },
744
+ image: { type: "string", title: "OG Image", format: "image-uri" },
745
+ themeColor: { type: "string", title: "Theme Color", format: "color" },
746
+ jsonLDs: {
747
+ type: "array",
748
+ title: "JSON-LD Structured Data",
749
+ items: { type: "object", additionalProperties: true },
750
+ },
751
+ },
752
+ };
753
+ manifestBlocks[SEOV2_TYPE] = {
754
+ $ref: `#/definitions/${seoV2Key}`,
755
+ namespace: "website",
756
+ };
757
+ extraAnyOf.push({ $ref: `#/definitions/${seoV2Key}` });
758
+
759
+ // --- website/flags/multivariate/section.ts ---
760
+ const MV_SECTION_TYPE = "website/flags/multivariate/section.ts";
761
+ const mvSectionKey = toBase64(MV_SECTION_TYPE);
762
+ definitions[mvSectionKey] = {
763
+ title: MV_SECTION_TYPE,
764
+ type: "object",
765
+ required: ["__resolveType"],
766
+ properties: {
767
+ __resolveType: {
768
+ type: "string",
769
+ enum: [MV_SECTION_TYPE],
770
+ default: MV_SECTION_TYPE,
771
+ },
772
+ variants: {
773
+ type: "array",
774
+ title: "Variants",
775
+ items: {
776
+ type: "object",
777
+ properties: {
778
+ rule: {
779
+ title: "Rule",
780
+ type: "object",
781
+ required: ["__resolveType"],
782
+ properties: { __resolveType: { type: "string" } },
783
+ additionalProperties: true,
784
+ },
785
+ value: {
786
+ title: "Section",
787
+ anyOf: sectionAnyOf,
788
+ },
789
+ },
790
+ },
791
+ },
792
+ },
793
+ };
794
+ manifestBlocks[MV_SECTION_TYPE] = {
795
+ $ref: `#/definitions/${mvSectionKey}`,
796
+ namespace: "website",
797
+ };
798
+ extraAnyOf.push({ $ref: `#/definitions/${mvSectionKey}` });
799
+
800
+ return { definitions, manifestBlocks, extraAnyOf };
801
+ }
802
+
803
+ // ---------------------------------------------------------------------------
804
+ // Post-processing: wrap complex properties with Resolvable anyOf
805
+ // ---------------------------------------------------------------------------
806
+
807
+ /**
808
+ * Walk all @Props definitions and wrap complex array/object properties
809
+ * with anyOf [Resolvable, original, ...matchingLoaders].
810
+ *
811
+ * In the deco CMS, ANY complex property can be replaced by a loader
812
+ * reference ({ __resolveType: "some/loader.ts", props: {...} }).
813
+ * This function ensures the schema accepts both inline data and
814
+ * loader references for all such properties.
815
+ */
816
+ function wrapResolvableProperties(
817
+ definitions: Record<string, any>,
818
+ _loaderDefinitions: Record<string, any>,
819
+ ) {
820
+ const resolvableRef = { $ref: `#/definitions/${RESOLVABLE_LITERAL_KEY}` };
821
+
822
+ const productLoaderRefs = getProductListLoaderKeys().map((key) => ({
823
+ $ref: `#/definitions/${toBase64(key)}`,
824
+ }));
825
+
826
+ for (const [defKey, def] of Object.entries(definitions)) {
827
+ if (!defKey.endsWith("@Props")) continue;
828
+ if (!def || !def.properties) continue;
829
+
830
+ for (const [propName, propSchema] of Object.entries(def.properties as Record<string, any>)) {
831
+ if (!propSchema || typeof propSchema !== "object") continue;
832
+ if (propSchema.anyOf || propSchema.$ref) continue;
833
+
834
+ const shouldWrap = isLoaderCompatibleProperty(propSchema);
835
+ if (!shouldWrap) continue;
836
+
837
+ const { nullable, title, hide, ...rest } = propSchema;
838
+
839
+ // Determine which loader refs to include based on property type
840
+ const loaderRefs = isProductArrayProperty(propSchema) ? productLoaderRefs : [];
841
+
842
+ const wrapped: any = {
843
+ anyOf: [resolvableRef, { ...rest, title: title || "Inline data" }, ...loaderRefs],
844
+ };
845
+ if (nullable) wrapped.nullable = true;
846
+ if (title) wrapped.title = title;
847
+ if (hide) wrapped.hide = hide;
848
+
849
+ def.properties[propName] = wrapped;
850
+ }
851
+
852
+ // Also walk nested object properties (e.g., Tab.products inside tabs array items)
853
+ wrapNestedProperties(def, resolvableRef, productLoaderRefs);
854
+ }
855
+ }
856
+
857
+ function isLoaderCompatibleProperty(schema: any): boolean {
858
+ if (schema.type === "array" && schema.items?.type === "object") {
859
+ const propCount = Object.keys(schema.items.properties || {}).length;
860
+ return propCount > 3;
861
+ }
862
+ return false;
863
+ }
864
+
865
+ function isProductArrayProperty(schema: any): boolean {
866
+ if (schema.type !== "array" || !schema.items?.properties) return false;
867
+ const itemProps = schema.items.properties;
868
+ return !!(itemProps.productID || itemProps.name || itemProps.offers || itemProps.brand);
869
+ }
870
+
871
+ /**
872
+ * Recursively walk nested object/array schemas to wrap deeply nested
873
+ * loader-compatible properties. Handles cases like Tab.products where
874
+ * the products field is inside an array item's object schema.
875
+ */
876
+ function wrapNestedProperties(
877
+ schema: any,
878
+ resolvableRef: any,
879
+ productLoaderRefs: any[],
880
+ depth = 0,
881
+ ) {
882
+ if (depth > 5 || !schema || typeof schema !== "object") return;
883
+
884
+ if (schema.type === "array" && schema.items?.type === "object" && schema.items.properties) {
885
+ for (const [propName, propSchema] of Object.entries(
886
+ schema.items.properties as Record<string, any>,
887
+ )) {
888
+ if (!propSchema || typeof propSchema !== "object") continue;
889
+ if (propSchema.anyOf || propSchema.$ref) continue;
890
+
891
+ if (isLoaderCompatibleProperty(propSchema)) {
892
+ const { nullable, title, hide, ...rest } = propSchema;
893
+ const loaderRefs = isProductArrayProperty(propSchema) ? productLoaderRefs : [];
894
+ const wrapped: any = {
895
+ anyOf: [resolvableRef, { ...rest, title: title || "Inline data" }, ...loaderRefs],
896
+ };
897
+ if (nullable) wrapped.nullable = true;
898
+ if (title) wrapped.title = title;
899
+ if (hide) wrapped.hide = hide;
900
+ schema.items.properties[propName] = wrapped;
901
+ }
902
+
903
+ wrapNestedProperties(propSchema, resolvableRef, productLoaderRefs, depth + 1);
904
+ }
905
+ }
906
+
907
+ if (schema.properties) {
908
+ for (const propSchema of Object.values(schema.properties)) {
909
+ wrapNestedProperties(propSchema as any, resolvableRef, productLoaderRefs, depth + 1);
910
+ }
911
+ }
912
+ }
913
+
914
+ // ---------------------------------------------------------------------------
915
+ // composeMeta
916
+ // ---------------------------------------------------------------------------
917
+
918
+ const SECTION_REF_DEF_KEY = "__SECTION_REF__";
919
+
920
+ export function composeMeta(siteMeta: MetaResponse): MetaResponse {
921
+ const siteAnyOf = siteMeta.schema?.root?.sections?.anyOf || [];
922
+
923
+ // Build all framework components
924
+ const fwSections = buildFrameworkSections(siteAnyOf);
925
+ const fullSectionAnyOf = [...siteAnyOf, ...fwSections.extraAnyOf];
926
+ const page = buildPageSchema(fullSectionAnyOf);
927
+ const loaders = buildLoaderDefinitions();
928
+ const actions = buildActionDefinitions();
929
+ const matchers = buildMatcherDefinitions();
930
+
931
+ const sectionRefDef = { title: "Section", anyOf: fullSectionAnyOf };
932
+
933
+ const resolvableDef = buildResolvableDefinition();
934
+
935
+ // Merge all definitions.
936
+ // Priority (last wins): auto-registered empty schemas → framework sections/pages →
937
+ // site-generated schemas (meta.gen.json, with full loader props) → framework-critical keys.
938
+ // This ensures detailed site-generated schemas always win over auto-registered stubs.
939
+ const allDefinitions: Record<string, any> = {
940
+ ...loaders.definitions,
941
+ ...actions.definitions,
942
+ ...matchers.definitions,
943
+ ...fwSections.definitions,
944
+ ...page.definitions,
945
+ ...(siteMeta.schema?.definitions || {}),
946
+ [SECTION_REF_DEF_KEY]: sectionRefDef,
947
+ [RESOLVABLE_LITERAL_KEY]: resolvableDef,
948
+ [RESOLVABLE_B64_KEY]: resolvableDef,
949
+ };
950
+
951
+ // Post-process: wrap complex section properties with Resolvable anyOf
952
+ wrapResolvableProperties(allDefinitions, loaders.definitions);
953
+
954
+ return {
955
+ ...siteMeta,
956
+ framework: "tanstack-start",
957
+ manifest: {
958
+ blocks: {
959
+ ...(siteMeta.manifest?.blocks || {}),
960
+ sections: {
961
+ ...(siteMeta.manifest?.blocks?.sections || {}),
962
+ ...fwSections.manifestBlocks,
963
+ },
964
+ pages: {
965
+ ...(siteMeta.manifest?.blocks?.pages || {}),
966
+ ...page.manifestBlocks,
967
+ },
968
+ loaders: {
969
+ ...(siteMeta.manifest?.blocks?.loaders || {}),
970
+ ...loaders.manifestBlocks,
971
+ },
972
+ actions: {
973
+ ...(siteMeta.manifest?.blocks?.actions || {}),
974
+ ...actions.manifestBlocks,
975
+ },
976
+ matchers: {
977
+ ...(siteMeta.manifest?.blocks?.matchers || {}),
978
+ ...matchers.manifestBlocks,
979
+ },
980
+ },
981
+ },
982
+ schema: {
983
+ definitions: allDefinitions,
984
+ root: {
985
+ ...(siteMeta.schema?.root || {}),
986
+ sections: { anyOf: fullSectionAnyOf },
987
+ pages: { anyOf: page.rootAnyOf },
988
+ loaders: { anyOf: loaders.loaderAnyOf },
989
+ matchers: { anyOf: matchers.matcherAnyOf },
990
+ },
991
+ },
992
+ };
993
+ }