@decocms/blocks 7.34.1 → 7.34.2

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.34.1",
3
+ "version": "7.34.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -1,5 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
- import { findPageByPath, matchPath, setBlocks } from "./loader";
2
+ import { setDraftOverrideGetter } from "./draftSource";
3
+ import { findPageByPath, loadBlocks, matchPath, setBlocks } from "./loader";
3
4
 
4
5
  // Mirrors the behavior of the original deco-cx/deco Fresh framework
5
6
  // (runtime/features/render.tsx), which uses native `URLPattern` directly
@@ -198,3 +199,85 @@ describe("findPageByPath specificity", () => {
198
199
  expect(findPageByPath("/nope")).toBeNull();
199
200
  });
200
201
  });
202
+
203
+ describe("loadBlocks draft override — key percent-encoding", () => {
204
+ // The published decofile encodes special characters in block keys
205
+ // (`pages-Home%20(principal)-1`); the Studio draft-preview sandbox emits them
206
+ // raw (`pages-Home (principal)-1`). A naive key-merge ADDS the raw-keyed
207
+ // draft block instead of REPLACING the encoded published one, leaving two
208
+ // `pages-` blocks with path "/" — and findPageByPath returns the first
209
+ // (published) one, so the draft edit silently never renders. This shipped and
210
+ // made ~73% of casaevideo's pages (home + afiliados included) ignore drafts.
211
+
212
+ afterEach(() => {
213
+ setBlocks({});
214
+ setDraftOverrideGetter(() => undefined);
215
+ });
216
+
217
+ it("replaces an encoded base key with its raw-encoded draft twin (home)", () => {
218
+ setBlocks({
219
+ "pages-Home%20(principal)-1": {
220
+ name: "Home",
221
+ path: "/",
222
+ sections: [{ __resolveType: "published" }],
223
+ },
224
+ });
225
+ setDraftOverrideGetter(() => ({
226
+ "pages-Home (principal)-1": {
227
+ name: "Home",
228
+ path: "/",
229
+ sections: [{ __resolveType: "draft" }],
230
+ },
231
+ }));
232
+
233
+ // Exactly one page for "/" survives the merge — no encoded/raw duplicate.
234
+ const pageBlocks = Object.keys(loadBlocks()).filter((k) =>
235
+ k.startsWith("pages-"),
236
+ );
237
+ expect(pageBlocks).toHaveLength(1);
238
+
239
+ const match = findPageByPath("/");
240
+ const sections = match?.page.sections as Array<{ __resolveType: string }>;
241
+ expect(sections[0].__resolveType).toBe("draft");
242
+ });
243
+
244
+ it("still replaces a plain (unescaped) base key", () => {
245
+ setBlocks({
246
+ "pages-disney-1": {
247
+ name: "Disney",
248
+ path: "/disney",
249
+ sections: [{ __resolveType: "published" }],
250
+ },
251
+ });
252
+ setDraftOverrideGetter(() => ({
253
+ "pages-disney-1": {
254
+ name: "Disney",
255
+ path: "/disney",
256
+ sections: [{ __resolveType: "draft" }],
257
+ },
258
+ }));
259
+
260
+ const match = findPageByPath("/disney");
261
+ const sections = match?.page.sections as Array<{ __resolveType: string }>;
262
+ expect(sections[0].__resolveType).toBe("draft");
263
+ });
264
+
265
+ it("a null draft value removes the encoded base twin too", () => {
266
+ setBlocks({
267
+ "pages-Home%20(principal)-1": {
268
+ name: "Home",
269
+ path: "/",
270
+ sections: [{ __resolveType: "published" }],
271
+ },
272
+ });
273
+ setDraftOverrideGetter(() => ({
274
+ "pages-Home (principal)-1": null,
275
+ }));
276
+
277
+ expect(findPageByPath("/")).toBeNull();
278
+ const pageBlocks = Object.keys(loadBlocks()).filter((k) =>
279
+ k.startsWith("pages-"),
280
+ );
281
+ expect(pageBlocks).toHaveLength(0);
282
+ });
283
+ });
package/src/cms/loader.ts CHANGED
@@ -85,6 +85,59 @@ export function setBlocks(blocks: Record<string, unknown>) {
85
85
  }
86
86
  }
87
87
 
88
+ /**
89
+ * Canonicalise a block key so an override block matches its base twin even when
90
+ * the two decofiles disagree on percent-encoding of the key.
91
+ *
92
+ * The site's published decofile encodes special characters in block keys
93
+ * (`pages-Home%20(principal)-287364`); the Studio draft-preview sandbox emits
94
+ * them raw (`pages-Home (principal)-287364`). Decoding collapses both spellings
95
+ * to the same canonical form. Defensive: a malformed `%` sequence makes
96
+ * `decodeURIComponent` throw, so fall back to the raw key rather than 500 the
97
+ * merge.
98
+ */
99
+ function canonicalBlockKey(key: string): string {
100
+ try {
101
+ return decodeURIComponent(key);
102
+ } catch {
103
+ return key;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Merge an override decofile on top of the base blocks.
109
+ *
110
+ * An override entry REPLACES the base block of the same logical key — including
111
+ * a base twin spelled with different percent-encoding. A naive key-merge would
112
+ * instead ADD the differently-encoded override block, leaving two `pages-`
113
+ * blocks with the same `.path`; `findPageByPath` returns the first (base) one,
114
+ * so a draft-preview edit would silently never render. Canonicalising the base
115
+ * keys lets the override find and displace its twin.
116
+ *
117
+ * A `null`/`undefined` override value deletes the block (its twin too), so a
118
+ * draft that removes a block is honoured.
119
+ */
120
+ function mergeOverride(
121
+ base: Record<string, unknown>,
122
+ override: Record<string, unknown>,
123
+ ): Record<string, unknown> {
124
+ const merged = { ...base };
125
+ const baseByCanonical = new Map<string, string>();
126
+ for (const baseKey of Object.keys(merged)) {
127
+ baseByCanonical.set(canonicalBlockKey(baseKey), baseKey);
128
+ }
129
+ for (const [key, value] of Object.entries(override)) {
130
+ const twin = baseByCanonical.get(canonicalBlockKey(key));
131
+ if (twin !== undefined && twin !== key) delete merged[twin];
132
+ if (value === null || value === undefined) {
133
+ delete merged[key];
134
+ } else {
135
+ merged[key] = value;
136
+ }
137
+ }
138
+ return merged;
139
+ }
140
+
88
141
  /**
89
142
  * Load the current blocks. If running inside a `withBlocksOverride` scope
90
143
  * (admin preview) or a request carrying a draft-preview override, that
@@ -101,17 +154,7 @@ export function loadBlocks(): Record<string, unknown> {
101
154
  // over an ambient draft: the caller named the exact blocks to render, so a
102
155
  // draft pointer on the same request must not silently replace them.
103
156
  const override = blocksOverrideStorage.getStore() ?? getRequestDraftOverride();
104
- if (override) {
105
- const merged = { ...blockData };
106
- for (const [key, value] of Object.entries(override)) {
107
- if (value === null || value === undefined) {
108
- delete merged[key];
109
- } else {
110
- merged[key] = value;
111
- }
112
- }
113
- return merged;
114
- }
157
+ if (override) return mergeOverride(blockData, override);
115
158
  return blockData;
116
159
  }
117
160