@decocms/blocks 7.9.0 → 7.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.9.0",
3
+ "version": "7.10.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework-agnostic core: CMS block resolution, section registry, matchers, portable SDK utilities",
6
6
  "repository": {
package/src/cms/client.ts CHANGED
@@ -51,13 +51,22 @@ export {
51
51
  } from "./registry";
52
52
  export type { SectionLoaderFn } from "./sectionLoaders";
53
53
  export { compose, withDevice, withMobile, withSearchParam, withSectionLoader } from "./sectionMixins";
54
- export type { ActionConfig, LoaderConfig, MatcherConfig, MetaResponse } from "./schema";
54
+ export type {
55
+ ActionConfig,
56
+ AppSchemas,
57
+ BlockPropsSchema,
58
+ LoaderConfig,
59
+ MatcherConfig,
60
+ MetaResponse,
61
+ } from "./schema";
55
62
  export {
56
63
  composeMeta,
57
64
  getRegisteredLoaders,
58
65
  getRegisteredMatchers,
66
+ inferLoaderTags,
59
67
  registerActionSchema,
60
68
  registerActionSchemas,
69
+ registerAppSchemas,
61
70
  registerLoaderSchema,
62
71
  registerLoaderSchemas,
63
72
  registerMatcherSchema,
package/src/cms/index.ts CHANGED
@@ -100,13 +100,22 @@ export {
100
100
  } from "./sectionMixins";
101
101
  export type { ApplySectionConventionsInput, SectionMetaEntry } from "./applySectionConventions";
102
102
  export { applySectionConventions } from "./applySectionConventions";
103
- export type { ActionConfig, LoaderConfig, MatcherConfig, MetaResponse } from "./schema";
103
+ export type {
104
+ ActionConfig,
105
+ AppSchemas,
106
+ BlockPropsSchema,
107
+ LoaderConfig,
108
+ MatcherConfig,
109
+ MetaResponse,
110
+ } from "./schema";
104
111
  export {
105
112
  composeMeta,
106
113
  getRegisteredLoaders,
107
114
  getRegisteredMatchers,
115
+ inferLoaderTags,
108
116
  registerActionSchema,
109
117
  registerActionSchemas,
118
+ registerAppSchemas,
110
119
  registerLoaderSchema,
111
120
  registerLoaderSchemas,
112
121
  registerMatcherSchema,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type ActionConfig,
3
+ inferLoaderTags,
3
4
  type LoaderConfig,
4
5
  registerActionSchemas,
5
6
  registerLoaderSchemas,
@@ -456,6 +457,9 @@ export function registerCommerceLoaders(loaders: Record<string, CommerceLoader>)
456
457
 
457
458
  // Auto-register loader + action schemas for the admin manifest.
458
459
  // Separate actions (keys containing "/actions/") from loaders.
460
+ // These are stub registrations (isStub) — a real schema for the same key
461
+ // (registered via registerAppSchemas from an app's schemas.gen.ts) always
462
+ // wins, no matter which side registers first.
459
463
  const loaderConfigs: LoaderConfig[] = [];
460
464
  const actionConfigs: ActionConfig[] = [];
461
465
 
@@ -464,7 +468,7 @@ export function registerCommerceLoaders(loaders: Record<string, CommerceLoader>)
464
468
  const schema = { type: "object" as const, additionalProperties: true };
465
469
 
466
470
  if (key.includes("/actions/")) {
467
- actionConfigs.push({ key, title: key, namespace, propsSchema: schema });
471
+ actionConfigs.push({ key, title: key, namespace, propsSchema: schema, isStub: true });
468
472
  } else {
469
473
  loaderConfigs.push({
470
474
  key,
@@ -472,6 +476,7 @@ export function registerCommerceLoaders(loaders: Record<string, CommerceLoader>)
472
476
  namespace,
473
477
  propsSchema: schema,
474
478
  tags: inferLoaderTags(key),
479
+ isStub: true,
475
480
  });
476
481
  }
477
482
  }
@@ -480,18 +485,6 @@ export function registerCommerceLoaders(loaders: Record<string, CommerceLoader>)
480
485
  registerActionSchemas(actionConfigs);
481
486
  }
482
487
 
483
- function inferLoaderTags(key: string): string[] {
484
- if (
485
- key.includes("productList") ||
486
- key.includes("ProductList") ||
487
- key.includes("ProductShelf") ||
488
- key.includes("SearchResult")
489
- ) {
490
- return ["product-list"];
491
- }
492
- return [];
493
- }
494
-
495
488
  /** Delete a single commerce loader by key. No-op if key is absent. */
496
489
  export function unregisterCommerceLoader(key: string): void {
497
490
  delete commerceLoaders[key];
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Tests for the schema registry's real-over-stub precedence and the
3
+ * definition builders' additionalProperties preservation.
4
+ *
5
+ * Background: registerCommerceLoaders() auto-registers a
6
+ * `{ type: "object", additionalProperties: true }` stub for every commerce
7
+ * loader/action key. App packages register REAL props schemas (from their
8
+ * build-time schemas.gen.ts) via registerAppSchemas(). The published meta
9
+ * must contain the real schema no matter which side registered first, and
10
+ * the stub's additionalProperties flag must survive into the definition so
11
+ * the Studio can tell "props unknown — edit as JSON" apart from "takes no
12
+ * input".
13
+ *
14
+ * NOTE: the registries are module-global singletons — every test uses its own
15
+ * unique keys instead of resetting shared state.
16
+ */
17
+ import { describe, expect, it } from "vitest";
18
+ import {
19
+ composeMeta,
20
+ getRegisteredLoaders,
21
+ type MetaResponse,
22
+ registerActionSchema,
23
+ registerAppSchemas,
24
+ registerLoaderSchema,
25
+ } from "./schema";
26
+
27
+ const b64 = (s: string) => Buffer.from(s).toString("base64");
28
+
29
+ const emptySiteMeta = (): MetaResponse => ({
30
+ major: 1,
31
+ version: "1.0.0",
32
+ namespace: "site",
33
+ site: "test",
34
+ manifest: { blocks: {} },
35
+ schema: { definitions: {}, root: {} },
36
+ });
37
+
38
+ const STUB_SCHEMA = { type: "object" as const, additionalProperties: true };
39
+
40
+ function stubLoader(key: string) {
41
+ registerLoaderSchema({
42
+ key,
43
+ title: key,
44
+ namespace: "vtex",
45
+ propsSchema: STUB_SCHEMA,
46
+ isStub: true,
47
+ });
48
+ }
49
+
50
+ const REAL_PLP_PROPS = {
51
+ type: "object" as const,
52
+ properties: {
53
+ sort: {
54
+ type: "string",
55
+ enum: ["OrderByPriceDESC", "OrderByPriceASC", "OrderByTopSaleDESC"],
56
+ },
57
+ count: { type: "number" },
58
+ fq: { type: "string" },
59
+ },
60
+ required: ["count"],
61
+ };
62
+
63
+ function realLoader(key: string) {
64
+ registerAppSchemas({ namespace: "vtex", loaders: { [key]: REAL_PLP_PROPS } });
65
+ }
66
+
67
+ function definitionFor(key: string) {
68
+ return composeMeta(emptySiteMeta()).schema.definitions[b64(key)];
69
+ }
70
+
71
+ describe("real-over-stub precedence", () => {
72
+ it("keeps the real schema when the stub registers afterwards (loaders)", () => {
73
+ const key = "vtex/loaders/test/realThenStub.ts";
74
+ realLoader(key);
75
+ stubLoader(key);
76
+
77
+ const def = definitionFor(key);
78
+ expect(def.properties.sort.enum).toEqual([
79
+ "OrderByPriceDESC",
80
+ "OrderByPriceASC",
81
+ "OrderByTopSaleDESC",
82
+ ]);
83
+ expect(def.required).toEqual(["__resolveType", "count"]);
84
+ expect(def.additionalProperties).toBeUndefined();
85
+ });
86
+
87
+ it("replaces the stub when the real schema registers afterwards (loaders)", () => {
88
+ const key = "vtex/loaders/test/stubThenReal.ts";
89
+ stubLoader(key);
90
+ realLoader(key);
91
+
92
+ const def = definitionFor(key);
93
+ expect(def.properties.count).toEqual({ type: "number" });
94
+ expect(def.additionalProperties).toBeUndefined();
95
+ });
96
+
97
+ it("applies the same precedence to actions", () => {
98
+ const key = "vtex/actions/test/addCoupon.ts";
99
+ registerAppSchemas({
100
+ namespace: "vtex",
101
+ actions: {
102
+ [key]: {
103
+ type: "object",
104
+ properties: { coupon: { type: "string" } },
105
+ required: ["coupon"],
106
+ },
107
+ },
108
+ });
109
+ registerActionSchema({
110
+ key,
111
+ title: key,
112
+ namespace: "vtex",
113
+ propsSchema: STUB_SCHEMA,
114
+ isStub: true,
115
+ });
116
+
117
+ const def = definitionFor(key);
118
+ expect(def.properties.coupon).toEqual({ type: "string" });
119
+ expect(def.required).toEqual(["__resolveType", "coupon"]);
120
+ expect(def.additionalProperties).toBeUndefined();
121
+ });
122
+
123
+ it("lets a later stub replace an earlier stub (hot-reload re-registration)", () => {
124
+ const key = "vtex/loaders/test/stubTwice.ts";
125
+ stubLoader(key);
126
+ stubLoader(key);
127
+ expect(definitionFor(key).additionalProperties).toBe(true);
128
+ });
129
+
130
+ it("lets a later real schema replace an earlier real schema", () => {
131
+ const key = "vtex/loaders/test/realTwice.ts";
132
+ realLoader(key);
133
+ registerAppSchemas({
134
+ namespace: "vtex",
135
+ loaders: {
136
+ [key]: { type: "object", properties: { slug: { type: "string" } } },
137
+ },
138
+ });
139
+ const def = definitionFor(key);
140
+ expect(def.properties.slug).toEqual({ type: "string" });
141
+ expect(def.properties.sort).toBeUndefined();
142
+ });
143
+ });
144
+
145
+ describe("additionalProperties preservation in definitions", () => {
146
+ it("emits additionalProperties: true for stub-only keys (props unknown → JSON editor)", () => {
147
+ const key = "vtex/loaders/test/stubOnly.ts";
148
+ stubLoader(key);
149
+
150
+ const def = definitionFor(key);
151
+ expect(def.additionalProperties).toBe(true);
152
+ expect(Object.keys(def.properties)).toEqual(["__resolveType"]);
153
+ });
154
+
155
+ it("emits NO additionalProperties for a real schema without props (takes no input)", () => {
156
+ const key = "vtex/loaders/test/noInput.ts";
157
+ registerAppSchemas({
158
+ namespace: "vtex",
159
+ loaders: { [key]: { type: "object", properties: {} } },
160
+ });
161
+
162
+ const def = definitionFor(key);
163
+ expect(def.additionalProperties).toBeUndefined();
164
+ expect(Object.keys(def.properties)).toEqual(["__resolveType"]);
165
+ });
166
+ });
167
+
168
+ describe("registerAppSchemas", () => {
169
+ it("registers every key form present in the artifact (bare and .ts)", () => {
170
+ const bare = "vtex/loaders/test/aliased";
171
+ registerAppSchemas({
172
+ namespace: "vtex",
173
+ loaders: {
174
+ [bare]: REAL_PLP_PROPS,
175
+ [`${bare}.ts`]: REAL_PLP_PROPS,
176
+ },
177
+ });
178
+
179
+ const meta = composeMeta(emptySiteMeta());
180
+ for (const key of [bare, `${bare}.ts`]) {
181
+ const def = meta.schema.definitions[b64(key)];
182
+ expect(def.properties.sort.enum).toContain("OrderByPriceDESC");
183
+ expect(def.properties.__resolveType.enum).toEqual([key]);
184
+ expect(meta.manifest.blocks.loaders[key]).toEqual({
185
+ $ref: `#/definitions/${b64(key)}`,
186
+ namespace: "vtex",
187
+ });
188
+ }
189
+ });
190
+
191
+ it("propagates schema title/description into the definition", () => {
192
+ const key = "vtex/loaders/test/titled.ts";
193
+ registerAppSchemas({
194
+ namespace: "vtex",
195
+ loaders: {
196
+ [key]: {
197
+ type: "object",
198
+ title: "Product Listing Page",
199
+ description: "Fetches a PLP",
200
+ properties: { term: { type: "string" } },
201
+ },
202
+ },
203
+ });
204
+
205
+ const def = definitionFor(key);
206
+ expect(def.title).toBe("Product Listing Page");
207
+ expect(def.description).toBe("Fetches a PLP");
208
+ });
209
+
210
+ it("infers the product-list tag for loader keys, matching the stub inference", () => {
211
+ const key = "vtex/loaders/test/productListShelf.ts";
212
+ registerAppSchemas({
213
+ namespace: "vtex",
214
+ loaders: { [key]: { type: "object", properties: {} } },
215
+ });
216
+
217
+ const registered = getRegisteredLoaders().find((l) => l.key === key);
218
+ expect(registered?.tags).toEqual(["product-list"]);
219
+ });
220
+ });
package/src/cms/schema.ts CHANGED
@@ -68,6 +68,12 @@ export interface LoaderConfig {
68
68
  propsSchema: Record<string, any>;
69
69
  /** Tags for property matching (e.g., "product-list" enables injection into Product[] props). */
70
70
  tags?: string[];
71
+ /**
72
+ * Auto-registered placeholder with no real props (see registerCommerceLoaders).
73
+ * A stub never overwrites a real schema for the same key, regardless of
74
+ * registration order.
75
+ */
76
+ isStub?: boolean;
71
77
  }
72
78
 
73
79
  const loaderRegistry: LoaderConfig[] = [];
@@ -76,6 +82,7 @@ const loaderRegistry: LoaderConfig[] = [];
76
82
  export function registerLoaderSchema(config: LoaderConfig) {
77
83
  const idx = loaderRegistry.findIndex((l) => l.key === config.key);
78
84
  if (idx >= 0) {
85
+ if (config.isStub && !loaderRegistry[idx].isStub) return;
79
86
  loaderRegistry[idx] = config;
80
87
  } else {
81
88
  loaderRegistry.push(config);
@@ -107,6 +114,8 @@ export interface ActionConfig {
107
114
  description?: string;
108
115
  icon?: string;
109
116
  propsSchema: Record<string, any>;
117
+ /** Auto-registered placeholder — never overwrites a real schema. See LoaderConfig.isStub. */
118
+ isStub?: boolean;
110
119
  }
111
120
 
112
121
  const actionRegistry: ActionConfig[] = [];
@@ -115,6 +124,7 @@ const actionRegistry: ActionConfig[] = [];
115
124
  export function registerActionSchema(config: ActionConfig) {
116
125
  const idx = actionRegistry.findIndex((a) => a.key === config.key);
117
126
  if (idx >= 0) {
127
+ if (config.isStub && !actionRegistry[idx].isStub) return;
118
128
  actionRegistry[idx] = config;
119
129
  } else {
120
130
  actionRegistry.push(config);
@@ -126,6 +136,74 @@ export function registerActionSchemas(configs: ActionConfig[]) {
126
136
  for (const config of configs) registerActionSchema(config);
127
137
  }
128
138
 
139
+ // ---------------------------------------------------------------------------
140
+ // App block schemas — real props generated at app build time
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Loader keys matching these substrings return product lists — the admin uses
145
+ * the "product-list" tag to offer them as loader pickers on Product[] props.
146
+ */
147
+ export function inferLoaderTags(key: string): string[] {
148
+ if (
149
+ key.includes("productList") ||
150
+ key.includes("ProductList") ||
151
+ key.includes("ProductShelf") ||
152
+ key.includes("SearchResult")
153
+ ) {
154
+ return ["product-list"];
155
+ }
156
+ return [];
157
+ }
158
+
159
+ /**
160
+ * Props schema of a single loader/action as emitted by blocks-cli's
161
+ * generate-app-schemas.ts into an app package's src/schemas.gen.ts.
162
+ * Same shape generate-schema.ts produces for site loaders.
163
+ */
164
+ export interface BlockPropsSchema {
165
+ type?: "object";
166
+ title?: string;
167
+ description?: string;
168
+ properties?: Record<string, any>;
169
+ required?: string[];
170
+ additionalProperties?: boolean;
171
+ /** Any other JSON Schema keyword or JSDoc passthrough tag (nullable, …). */
172
+ [keyword: string]: any;
173
+ }
174
+
175
+ export interface AppSchemas {
176
+ namespace: string;
177
+ loaders?: Record<string, BlockPropsSchema>;
178
+ actions?: Record<string, BlockPropsSchema>;
179
+ }
180
+
181
+ /**
182
+ * Register the real loader/action props schemas an app package generated at
183
+ * build time (src/schemas.gen.ts). These take precedence over the
184
+ * `__resolveType`-only stubs auto-registered by registerCommerceLoaders(),
185
+ * regardless of which side registers first.
186
+ */
187
+ export function registerAppSchemas(app: AppSchemas): void {
188
+ const loaderConfigs: LoaderConfig[] = Object.entries(app.loaders ?? {}).map(([key, schema]) => ({
189
+ key,
190
+ title: schema.title || key,
191
+ namespace: app.namespace,
192
+ ...(schema.description ? { description: schema.description } : {}),
193
+ propsSchema: schema,
194
+ tags: inferLoaderTags(key),
195
+ }));
196
+ const actionConfigs: ActionConfig[] = Object.entries(app.actions ?? {}).map(([key, schema]) => ({
197
+ key,
198
+ title: schema.title || key,
199
+ namespace: app.namespace,
200
+ ...(schema.description ? { description: schema.description } : {}),
201
+ propsSchema: schema,
202
+ }));
203
+ registerLoaderSchemas(loaderConfigs);
204
+ registerActionSchemas(actionConfigs);
205
+ }
206
+
129
207
  function buildActionDefinitions() {
130
208
  const definitions: Record<string, any> = {};
131
209
  const manifestBlocks: Record<string, any> = {};
@@ -134,7 +212,7 @@ function buildActionDefinitions() {
134
212
  const defKey = toBase64(action.key);
135
213
 
136
214
  definitions[defKey] = {
137
- title: action.key,
215
+ title: action.title || action.key,
138
216
  ...(action.description ? { description: action.description } : {}),
139
217
  ...(action.icon ? { icon: action.icon } : {}),
140
218
  type: "object",
@@ -147,6 +225,9 @@ function buildActionDefinitions() {
147
225
  },
148
226
  ...(action.propsSchema?.properties || {}),
149
227
  },
228
+ // Preserved so the admin can tell "props unknown — edit as JSON" (stub)
229
+ // apart from "takes no input" (real schema without the flag).
230
+ ...(action.propsSchema?.additionalProperties === true ? { additionalProperties: true } : {}),
150
231
  };
151
232
 
152
233
  manifestBlocks[action.key] = {
@@ -493,7 +574,7 @@ function buildLoaderDefinitions() {
493
574
  const defKey = toBase64(loader.key);
494
575
 
495
576
  definitions[defKey] = {
496
- title: loader.key,
577
+ title: loader.title || loader.key,
497
578
  ...(loader.description ? { description: loader.description } : {}),
498
579
  ...(loader.icon ? { icon: loader.icon } : {}),
499
580
  type: "object",
@@ -506,6 +587,9 @@ function buildLoaderDefinitions() {
506
587
  },
507
588
  ...(loader.propsSchema?.properties || {}),
508
589
  },
590
+ // Preserved so the admin can tell "props unknown — edit as JSON" (stub)
591
+ // apart from "takes no input" (real schema without the flag).
592
+ ...(loader.propsSchema?.additionalProperties === true ? { additionalProperties: true } : {}),
509
593
  };
510
594
 
511
595
  manifestBlocks[loader.key] = {
@@ -544,6 +628,7 @@ function buildMatcherDefinitions() {
544
628
  },
545
629
  ...(matcher.propsSchema?.properties || {}),
546
630
  },
631
+ ...(matcher.propsSchema?.additionalProperties === true ? { additionalProperties: true } : {}),
547
632
  };
548
633
  manifestBlocks[matcher.key] = {
549
634
  $ref: `#/definitions/${defKey}`,