@decocms/blocks 7.16.2 → 7.16.4

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.16.2",
3
+ "version": "7.16.4",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -1,7 +1,7 @@
1
1
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { loadDecofileDirectory } from "./loadDecofileDirectory";
6
6
 
7
7
  describe("loadDecofileDirectory", () => {
@@ -49,4 +49,61 @@ describe("loadDecofileDirectory", () => {
49
49
  writeFileSync(join(dir, "broken.json"), "{not valid json");
50
50
  await expect(loadDecofileDirectory(dir)).rejects.toThrow(/broken\.json/);
51
51
  });
52
+
53
+ it("URL-decodes the filename stem once to produce the block key", async () => {
54
+ // Classic deco stores blocks as encodeURIComponent(<block id>).json and
55
+ // derives the id back with one decodeURIComponent (parseBlockId). Content
56
+ // references saved blocks by the DECODED id ("Cores dos preços"), so the
57
+ // map must be keyed by it or every such reference dangles.
58
+ writeFileSync(
59
+ join(dir, "Cores%20dos%20pre%C3%A7os.json"),
60
+ JSON.stringify({ __resolveType: "site/loaders/priceColors.ts" }),
61
+ );
62
+ // Double-encoded page snapshot (deco.cx corpus shape): decodes ONCE.
63
+ writeFileSync(
64
+ join(dir, "pages-Acess%25C3%25B3rios-421596.json"),
65
+ JSON.stringify({ __resolveType: "website/pages/Page.tsx" }),
66
+ );
67
+ // Encoded slash becomes a real slash in the id — ids are plain map keys.
68
+ writeFileSync(join(dir, "collections%2Fblog%2Fauthors.json"), JSON.stringify({ authors: [] }));
69
+
70
+ const blocks = await loadDecofileDirectory(dir);
71
+
72
+ expect(blocks["Cores dos preços"]).toEqual({
73
+ __resolveType: "site/loaders/priceColors.ts",
74
+ });
75
+ expect(blocks["pages-Acess%C3%B3rios-421596"]).toEqual({
76
+ __resolveType: "website/pages/Page.tsx",
77
+ });
78
+ expect(blocks["collections/blog/authors"]).toEqual({ authors: [] });
79
+ // The raw stems must NOT appear as keys.
80
+ expect(blocks["Cores%20dos%20pre%C3%A7os"]).toBeUndefined();
81
+ expect(blocks["pages-Acess%25C3%25B3rios-421596"]).toBeUndefined();
82
+ });
83
+
84
+ it("keeps a stem verbatim when it is not valid percent-encoding", async () => {
85
+ // A lone % (e.g. a page literally named "50% off") throws in
86
+ // decodeURIComponent — the stem is already the id, keep it.
87
+ writeFileSync(join(dir, "pages-50% off-7.json"), JSON.stringify({ x: 1 }));
88
+
89
+ const blocks = await loadDecofileDirectory(dir);
90
+ expect(blocks["pages-50% off-7"]).toEqual({ x: 1 });
91
+ });
92
+
93
+ it("resolves decoded-key collisions deterministically (last sorted filename wins) and warns", async () => {
94
+ writeFileSync(join(dir, "A B.json"), JSON.stringify({ raw: true }));
95
+ writeFileSync(join(dir, "A%20B.json"), JSON.stringify({ encoded: true }));
96
+
97
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
98
+ try {
99
+ const blocks = await loadDecofileDirectory(dir);
100
+ // Code-unit sort: " " (0x20) < "%" (0x25), so "A B.json" comes first
101
+ // and "A%20B.json" is processed last — the encoded file wins.
102
+ expect(blocks["A B"]).toEqual({ encoded: true });
103
+ expect(Object.keys(blocks)).toHaveLength(1);
104
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("A%20B.json"));
105
+ } finally {
106
+ warn.mockRestore();
107
+ }
108
+ });
52
109
  });
@@ -1,21 +1,50 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
 
4
+ /**
5
+ * Derive a block id (decofile map key) from a `.deco/blocks` filename.
6
+ *
7
+ * Classic deco stores each block as `encodeURIComponent(<block id>).json`
8
+ * and derives the id back with exactly one decodeURIComponent — this is
9
+ * upstream `parseBlockId` (deco-cx/deco, engine/decofile/fsFolder). Block
10
+ * CONTENT references saved blocks by the DECODED id (`"__resolveType":
11
+ * "Cores dos preços"`), and the Studio editor derives the write-back
12
+ * filename as `encodeURIComponent(<key>)`, so keying the map by the raw
13
+ * stem breaks both: references dangle at resolve time and editor saves
14
+ * land in a freshly-created double-encoded file.
15
+ *
16
+ * A stem that is not valid percent-encoding (e.g. a page literally named
17
+ * "50% off") throws in decodeURIComponent — such a stem IS the id already
18
+ * (classic deco would have stored it encoded), so it is kept verbatim,
19
+ * matching upstream behavior for pre-encoding-era files.
20
+ */
21
+ export function parseBlockId(filename: string): string {
22
+ const stem = filename.endsWith(".json") ? filename.slice(0, -".json".length) : filename;
23
+ try {
24
+ return decodeURIComponent(stem);
25
+ } catch {
26
+ return stem;
27
+ }
28
+ }
29
+
4
30
  /**
5
31
  * Loads a directory of legacy per-block JSON snapshot files (the format
6
32
  * produced by the pre-v2 Deco admin, e.g. `.deco/blocks/<name>.json`) into
7
33
  * a single blocks map suitable for setBlocks(). Each file becomes one
8
- * entry keyed by its filename minus the .json extension.
34
+ * entry keyed by `parseBlockId(<filename>)` the filename stem URL-decoded
35
+ * once, matching the classic deco runtime's key convention.
36
+ *
37
+ * Beyond that single decode the key is not renamed or normalized: whatever
38
+ * prefix convention a block's filename already carries (e.g. real page
39
+ * block snapshots are named `pages-<slug>-<id>.json`) is preserved, because
40
+ * getAllPages() in loader.ts filters blocks by `key.startsWith("pages-")`
41
+ * before findPageByPath() matches on each page's own `.path` field. So the
42
+ * key format IS load-bearing for page blocks — decoding never touches the
43
+ * `pages-` prefix itself, only the percent-escapes after it.
9
44
  *
10
- * The key is not renamed or normalized beyond stripping the extension:
11
- * whatever prefix convention a block's filename already carries (e.g. real
12
- * page block snapshots are named `pages-<slug>-<id>.json`) is preserved
13
- * verbatim, because getAllPages() in loader.ts filters blocks by
14
- * `key.startsWith("pages-")` before findPageByPath() matches on each page's
15
- * own `.path` field. So the key format IS load-bearing for page blocks —
16
- * it must keep whatever prefix its source filename had — but this loader
17
- * never needs to invent or choose that prefix itself, since it simply
18
- * passes the on-disk filename through.
45
+ * Two filenames can decode to the same id (`A B.json` + `A%20B.json`).
46
+ * Files are processed in sorted filename order and the last one wins, so
47
+ * the outcome is deterministic; a console.warn names the shadowed file.
19
48
  *
20
49
  * Replaces the abandoned @decocms/start/node tier's loadAllDecofileBlocks,
21
50
  * which no longer exists on any reachable blocks version — ported
@@ -28,21 +57,33 @@ export async function loadDecofileDirectory(dir: string): Promise<Record<string,
28
57
  const entries = await fs.readdir(dir, { withFileTypes: true });
29
58
  const blocks: Record<string, unknown> = {};
30
59
 
31
- await Promise.all(
32
- entries
33
- .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
34
- .map(async (entry) => {
35
- const key = entry.name.slice(0, -".json".length);
36
- const content = await fs.readFile(join(dir, entry.name), "utf8");
37
- try {
38
- blocks[key] = JSON.parse(content);
39
- } catch (error) {
40
- throw new Error(
41
- `loadDecofileDirectory: failed to parse ${entry.name}: ${(error as Error).message}`,
42
- );
43
- }
44
- }),
60
+ const files = entries
61
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
62
+ .map((entry) => entry.name)
63
+ .sort();
64
+
65
+ const parsed = await Promise.all(
66
+ files.map(async (name) => {
67
+ const content = await fs.readFile(join(dir, name), "utf8");
68
+ try {
69
+ return { name, value: JSON.parse(content) as unknown };
70
+ } catch (error) {
71
+ throw new Error(
72
+ `loadDecofileDirectory: failed to parse ${name}: ${(error as Error).message}`,
73
+ );
74
+ }
75
+ }),
45
76
  );
46
77
 
78
+ for (const { name, value } of parsed) {
79
+ const key = parseBlockId(name);
80
+ if (key in blocks) {
81
+ console.warn(
82
+ `loadDecofileDirectory: block id ${JSON.stringify(key)} maps to multiple files; ${JSON.stringify(name)} wins`,
83
+ );
84
+ }
85
+ blocks[key] = value;
86
+ }
87
+
47
88
  return blocks;
48
89
  }