@decocms/start 2.28.0 → 2.28.1
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 +1 -1
- package/scripts/generate-blocks.ts +47 -32
- package/scripts/lib/blocks-dedupe.test.ts +179 -0
- package/scripts/lib/blocks-dedupe.ts +128 -0
package/package.json
CHANGED
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import fs from "node:fs";
|
|
20
20
|
import path from "node:path";
|
|
21
|
+
import {
|
|
22
|
+
blockHasPath,
|
|
23
|
+
type Candidate,
|
|
24
|
+
decodeBlockNameWithPasses,
|
|
25
|
+
mergeCandidates,
|
|
26
|
+
} from "./lib/blocks-dedupe";
|
|
21
27
|
|
|
22
28
|
const args = process.argv.slice(2);
|
|
23
29
|
function arg(name: string, fallback: string): string {
|
|
@@ -29,20 +35,6 @@ const blocksDir = path.resolve(process.cwd(), arg("blocks-dir", ".deco/blocks"))
|
|
|
29
35
|
const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/blocks.gen.ts"));
|
|
30
36
|
const jsonFile = outFile.replace(/\.ts$/, ".json");
|
|
31
37
|
|
|
32
|
-
function decodeBlockName(filename: string): string {
|
|
33
|
-
let name = filename.replace(/\.json$/, "");
|
|
34
|
-
while (name.includes("%")) {
|
|
35
|
-
try {
|
|
36
|
-
const next = decodeURIComponent(name);
|
|
37
|
-
if (next === name) break;
|
|
38
|
-
name = next;
|
|
39
|
-
} catch {
|
|
40
|
-
break; // literal % in the decoded name — nothing left to decode
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return name;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
38
|
const TS_STUB = [
|
|
47
39
|
"// Auto-generated — thin wrapper around blocks.gen.json.",
|
|
48
40
|
"// The Vite plugin replaces this at load time with JSON.parse(...).",
|
|
@@ -62,30 +54,53 @@ if (!fs.existsSync(blocksDir)) {
|
|
|
62
54
|
|
|
63
55
|
const files = fs.readdirSync(blocksDir).filter((f) => f.endsWith(".json"));
|
|
64
56
|
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
57
|
+
// Read each file into a Candidate, then let the dedupe lib pick the winner
|
|
58
|
+
// per decoded key and report any collisions. See `lib/blocks-dedupe.ts` for
|
|
59
|
+
// the priority order and the rationale behind it (TL;DR: never use file size,
|
|
60
|
+
// don't trust mtime alone in CI clones).
|
|
61
|
+
const candidatesWithKeys: Array<{ candidate: Candidate; key: string }> = [];
|
|
68
62
|
for (const file of files) {
|
|
69
|
-
const name =
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
63
|
+
const { name, passes } = decodeBlockNameWithPasses(file);
|
|
64
|
+
const fp = path.join(blocksDir, file);
|
|
65
|
+
let parsed: unknown;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(fs.readFileSync(fp, "utf-8"));
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.warn(`Failed to parse ${file}:`, e);
|
|
76
70
|
continue;
|
|
77
71
|
}
|
|
78
|
-
|
|
72
|
+
candidatesWithKeys.push({
|
|
73
|
+
key: name,
|
|
74
|
+
candidate: {
|
|
75
|
+
file,
|
|
76
|
+
passes,
|
|
77
|
+
mtimeMs: fs.statSync(fp).mtimeMs,
|
|
78
|
+
hasPath: blockHasPath(parsed),
|
|
79
|
+
parsed,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
const { winners, collisions } = mergeCandidates(candidatesWithKeys);
|
|
85
|
+
|
|
86
|
+
if (collisions.length > 0) {
|
|
87
|
+
console.warn(
|
|
88
|
+
`Detected ${collisions.length} filename collision(s) in ${path.relative(process.cwd(), blocksDir)}:`,
|
|
89
|
+
);
|
|
90
|
+
for (const c of collisions) {
|
|
91
|
+
const losers = c.files.filter((f) => f !== c.winner);
|
|
92
|
+
console.warn(` - ${c.key}`);
|
|
93
|
+
console.warn(` winner: ${c.winner}`);
|
|
94
|
+
for (const l of losers) console.warn(` ignore: ${l}`);
|
|
88
95
|
}
|
|
96
|
+
console.warn(" Cause: multiple writers (manual sync vs deco-sync-bot) producing");
|
|
97
|
+
console.warn(" different filename encodings for the same logical key. Delete the");
|
|
98
|
+
console.warn(" stale file(s) listed under 'ignore' to silence this warning.");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const blocks: Record<string, unknown> = {};
|
|
102
|
+
for (const [name, c] of Object.entries(winners)) {
|
|
103
|
+
blocks[name] = c.parsed;
|
|
89
104
|
}
|
|
90
105
|
|
|
91
106
|
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
blockHasPath,
|
|
4
|
+
type Candidate,
|
|
5
|
+
decodeBlockName,
|
|
6
|
+
decodeBlockNameWithPasses,
|
|
7
|
+
mergeCandidates,
|
|
8
|
+
pickWinner,
|
|
9
|
+
} from "./blocks-dedupe";
|
|
10
|
+
|
|
11
|
+
const cand = (overrides: Partial<Candidate> & { file: string }): Candidate => ({
|
|
12
|
+
passes: 0,
|
|
13
|
+
mtimeMs: 0,
|
|
14
|
+
hasPath: true,
|
|
15
|
+
parsed: { path: "/" },
|
|
16
|
+
...overrides,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("decodeBlockNameWithPasses", () => {
|
|
20
|
+
it("returns the literal key with a 0 pass count when filename has no encoding", () => {
|
|
21
|
+
expect(decodeBlockNameWithPasses("Header.json")).toEqual({ name: "Header", passes: 0 });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("decodes a single layer of URL encoding", () => {
|
|
25
|
+
expect(decodeBlockNameWithPasses("pages-Home%20-%20LB-618509.json")).toEqual({
|
|
26
|
+
name: "pages-Home - LB-618509",
|
|
27
|
+
passes: 1,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("decodes the bot's double-encoded scheme through to the literal key", () => {
|
|
32
|
+
// Bot encodes the raw prod URL-encoded key once: `encodeURIComponent("pages-Home%20-%20LB-618509")`.
|
|
33
|
+
expect(decodeBlockNameWithPasses("pages-Home%2520-%2520LB-618509.json")).toEqual({
|
|
34
|
+
name: "pages-Home - LB-618509",
|
|
35
|
+
passes: 2,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("stops decoding when a literal % survives a round", () => {
|
|
40
|
+
// `%` alone isn't valid encoding — the loop catches the throw and stops.
|
|
41
|
+
expect(decodeBlockNameWithPasses("weird%percent.json")).toEqual({
|
|
42
|
+
name: "weird%percent",
|
|
43
|
+
passes: 0,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("decodeBlockName", () => {
|
|
49
|
+
it("matches decodeBlockNameWithPasses on the name", () => {
|
|
50
|
+
expect(decodeBlockName("pages-Home%2520-%2520LB-618509.json")).toBe("pages-Home - LB-618509");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("blockHasPath", () => {
|
|
55
|
+
it("returns true for live page blocks", () => {
|
|
56
|
+
expect(blockHasPath({ path: "/", sections: [] })).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns false when path is null (zombie entry)", () => {
|
|
60
|
+
expect(blockHasPath({ path: null, sections: [] })).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns false when path is missing", () => {
|
|
64
|
+
expect(blockHasPath({ sections: [] })).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns false for empty path strings", () => {
|
|
68
|
+
expect(blockHasPath({ path: "" })).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns false for non-objects", () => {
|
|
72
|
+
expect(blockHasPath(null)).toBe(false);
|
|
73
|
+
expect(blockHasPath("/")).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("pickWinner", () => {
|
|
78
|
+
it("prefers a candidate with a real path over a zombie", () => {
|
|
79
|
+
const live = cand({ file: "live.json", hasPath: true });
|
|
80
|
+
const zombie = cand({ file: "zombie.json", hasPath: false, parsed: { path: null } });
|
|
81
|
+
expect(pickWinner(live, zombie)).toBe(live);
|
|
82
|
+
expect(pickWinner(zombie, live)).toBe(live);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("prefers higher decode-pass count when path-status matches", () => {
|
|
86
|
+
// The lebiscuit reproduction case: a stale single-encoded leftover with a
|
|
87
|
+
// newer mtime and larger size loses to the bot's double-encoded fresh file.
|
|
88
|
+
const stale = cand({
|
|
89
|
+
file: "pages-Home%20-%20LB-618509.json",
|
|
90
|
+
passes: 1,
|
|
91
|
+
mtimeMs: 2_000_000,
|
|
92
|
+
});
|
|
93
|
+
const fresh = cand({
|
|
94
|
+
file: "pages-Home%2520-%2520LB-618509.json",
|
|
95
|
+
passes: 2,
|
|
96
|
+
mtimeMs: 1_000_000,
|
|
97
|
+
});
|
|
98
|
+
expect(pickWinner(stale, fresh)).toBe(fresh);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("falls through to mtime when pass count ties", () => {
|
|
102
|
+
const older = cand({ file: "a.json", passes: 1, mtimeMs: 1 });
|
|
103
|
+
const newer = cand({ file: "b.json", passes: 1, mtimeMs: 2 });
|
|
104
|
+
expect(pickWinner(older, newer)).toBe(newer);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("falls through to lex filename when everything else ties", () => {
|
|
108
|
+
const a = cand({ file: "a.json", passes: 0, mtimeMs: 5 });
|
|
109
|
+
const b = cand({ file: "b.json", passes: 0, mtimeMs: 5 });
|
|
110
|
+
expect(pickWinner(a, b)).toBe(a);
|
|
111
|
+
expect(pickWinner(b, a)).toBe(a);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("mergeCandidates", () => {
|
|
116
|
+
it("returns each candidate unchanged when there are no collisions", () => {
|
|
117
|
+
const a = cand({ file: "Header.json" });
|
|
118
|
+
const b = cand({ file: "Footer.json" });
|
|
119
|
+
const result = mergeCandidates([
|
|
120
|
+
{ candidate: a, key: "Header" },
|
|
121
|
+
{ candidate: b, key: "Footer" },
|
|
122
|
+
]);
|
|
123
|
+
expect(result.collisions).toEqual([]);
|
|
124
|
+
expect(result.winners).toEqual({ Header: a, Footer: b });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("records a collision and picks the winner", () => {
|
|
128
|
+
const stale = cand({ file: "pages-Home%20-%20LB-618509.json", passes: 1, mtimeMs: 5 });
|
|
129
|
+
const fresh = cand({ file: "pages-Home%2520-%2520LB-618509.json", passes: 2, mtimeMs: 1 });
|
|
130
|
+
const result = mergeCandidates([
|
|
131
|
+
{ candidate: stale, key: "pages-Home - LB-618509" },
|
|
132
|
+
{ candidate: fresh, key: "pages-Home - LB-618509" },
|
|
133
|
+
]);
|
|
134
|
+
expect(result.winners["pages-Home - LB-618509"]).toBe(fresh);
|
|
135
|
+
expect(result.collisions).toEqual([
|
|
136
|
+
{
|
|
137
|
+
key: "pages-Home - LB-618509",
|
|
138
|
+
files: ["pages-Home%20-%20LB-618509.json", "pages-Home%2520-%2520LB-618509.json"],
|
|
139
|
+
winner: "pages-Home%2520-%2520LB-618509.json",
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("collapses three-way collisions into one record without dropping the winner", () => {
|
|
145
|
+
const a = cand({ file: "a.json", passes: 0, mtimeMs: 1 });
|
|
146
|
+
const b = cand({ file: "b.json", passes: 1, mtimeMs: 2 });
|
|
147
|
+
const c = cand({ file: "c.json", passes: 2, mtimeMs: 3 });
|
|
148
|
+
const result = mergeCandidates([
|
|
149
|
+
{ candidate: a, key: "k" },
|
|
150
|
+
{ candidate: b, key: "k" },
|
|
151
|
+
{ candidate: c, key: "k" },
|
|
152
|
+
]);
|
|
153
|
+
expect(result.winners.k).toBe(c);
|
|
154
|
+
expect(result.collisions).toHaveLength(1);
|
|
155
|
+
expect(result.collisions[0].winner).toBe("c.json");
|
|
156
|
+
// a, b, and c should all be tracked (a and b as ignored, c as winner).
|
|
157
|
+
expect(new Set(result.collisions[0].files)).toEqual(new Set(["a.json", "b.json", "c.json"]));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("prefers the live page over a zombie even when zombie has more passes", () => {
|
|
161
|
+
const livePlain = cand({
|
|
162
|
+
file: "Home.json",
|
|
163
|
+
passes: 0,
|
|
164
|
+
hasPath: true,
|
|
165
|
+
parsed: { path: "/" },
|
|
166
|
+
});
|
|
167
|
+
const zombieEncoded = cand({
|
|
168
|
+
file: "Home%2520.json",
|
|
169
|
+
passes: 2,
|
|
170
|
+
hasPath: false,
|
|
171
|
+
parsed: { path: null },
|
|
172
|
+
});
|
|
173
|
+
const result = mergeCandidates([
|
|
174
|
+
{ candidate: zombieEncoded, key: "Home" },
|
|
175
|
+
{ candidate: livePlain, key: "Home" },
|
|
176
|
+
]);
|
|
177
|
+
expect(result.winners.Home).toBe(livePlain);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers used by `generate-blocks.ts` to choose between multiple files
|
|
3
|
+
* that decode to the same logical CMS block key.
|
|
4
|
+
*
|
|
5
|
+
* Background: the live decofile snapshot lives under `.deco/blocks/`, with
|
|
6
|
+
* one file per block. The filename is `encodeURIComponent(<rawProdKey>) +
|
|
7
|
+
* ".json"`. The Deco admin sometimes serves URL-encoded keys (e.g.
|
|
8
|
+
* `pages-Home%20-%20LB-618509`), so a single block can land on disk with
|
|
9
|
+
* different filenames depending on which writer produced it:
|
|
10
|
+
*
|
|
11
|
+
* - The `deco-sync-bot` (CI) encodes the raw prod key as-is, producing
|
|
12
|
+
* `pages-Home%2520-%2520LB-618509.json` (two decode passes back to the
|
|
13
|
+
* literal key).
|
|
14
|
+
* - The legacy manual `sync-decofile.ts` decoded keys to literal first,
|
|
15
|
+
* so it wrote `pages-Home%20-%20LB-618509.json` (one decode pass).
|
|
16
|
+
*
|
|
17
|
+
* Both files decode to the same logical key, so the block generator must
|
|
18
|
+
* pick one. Picking by file size is wrong (a shrunk live page gives a
|
|
19
|
+
* smaller JSON than the stale older snapshot, so size silently prefers
|
|
20
|
+
* stale); picking by mtime alone is wrong (fresh `git clone` writes all
|
|
21
|
+
* files with the clone time and erases temporal ordering).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface Candidate {
|
|
25
|
+
file: string;
|
|
26
|
+
passes: number;
|
|
27
|
+
mtimeMs: number;
|
|
28
|
+
hasPath: boolean;
|
|
29
|
+
parsed: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Repeatedly URL-decode the basename of `filename` until no `%` sequence
|
|
34
|
+
* remains. Returns the fully-decoded canonical key plus the number of
|
|
35
|
+
* decode rounds it took. Higher pass count = the writer encoded a key
|
|
36
|
+
* that itself contained `%XX` sequences = bot scheme. See module-level
|
|
37
|
+
* comment for why this matters.
|
|
38
|
+
*/
|
|
39
|
+
export function decodeBlockNameWithPasses(filename: string): {
|
|
40
|
+
name: string;
|
|
41
|
+
passes: number;
|
|
42
|
+
} {
|
|
43
|
+
let name = filename.replace(/\.json$/, "");
|
|
44
|
+
let passes = 0;
|
|
45
|
+
while (name.includes("%")) {
|
|
46
|
+
try {
|
|
47
|
+
const next = decodeURIComponent(name);
|
|
48
|
+
if (next === name) break;
|
|
49
|
+
name = next;
|
|
50
|
+
passes++;
|
|
51
|
+
} catch {
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { name, passes };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function decodeBlockName(filename: string): string {
|
|
59
|
+
return decodeBlockNameWithPasses(filename).name;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Tie-break two candidates that decode to the same key. Priority:
|
|
64
|
+
* 1. Block has a non-null `path` — beats zombie/orphan entries.
|
|
65
|
+
* 2. More decode passes — bot's "encode raw prod key"
|
|
66
|
+
* scheme wins over legacy
|
|
67
|
+
* "decode-then-encode" leftovers
|
|
68
|
+
* when prod uses URL-encoded keys
|
|
69
|
+
* (the only case that collides).
|
|
70
|
+
* 3. Newer mtime — last-write-wins for same scheme.
|
|
71
|
+
* 4. Lexicographic filename — deterministic last resort.
|
|
72
|
+
*/
|
|
73
|
+
export function pickWinner(a: Candidate, b: Candidate): Candidate {
|
|
74
|
+
if (a.hasPath !== b.hasPath) return a.hasPath ? a : b;
|
|
75
|
+
if (a.passes !== b.passes) return a.passes > b.passes ? a : b;
|
|
76
|
+
if (a.mtimeMs !== b.mtimeMs) return a.mtimeMs > b.mtimeMs ? a : b;
|
|
77
|
+
return a.file < b.file ? a : b;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** True iff a parsed block JSON looks like a live page (non-empty `.path`). */
|
|
81
|
+
export function blockHasPath(parsed: unknown): boolean {
|
|
82
|
+
return (
|
|
83
|
+
typeof parsed === "object" &&
|
|
84
|
+
parsed !== null &&
|
|
85
|
+
"path" in parsed &&
|
|
86
|
+
typeof (parsed as { path?: unknown }).path === "string" &&
|
|
87
|
+
(parsed as { path: string }).path.length > 0
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CollisionRecord {
|
|
92
|
+
key: string;
|
|
93
|
+
files: string[];
|
|
94
|
+
winner: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface MergeResult {
|
|
98
|
+
winners: Record<string, Candidate>;
|
|
99
|
+
collisions: CollisionRecord[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reduce a list of candidates into one winner per decoded key, recording
|
|
104
|
+
* every collision so the caller can surface it as a build warning.
|
|
105
|
+
*/
|
|
106
|
+
export function mergeCandidates(
|
|
107
|
+
candidates: Array<{ candidate: Candidate; key: string }>,
|
|
108
|
+
): MergeResult {
|
|
109
|
+
const winners: Record<string, Candidate> = {};
|
|
110
|
+
// Track every file that decoded to a given key so three-way (and beyond)
|
|
111
|
+
// collisions don't lose the eventual winner from the file list.
|
|
112
|
+
const filesByKey: Record<string, string[]> = {};
|
|
113
|
+
for (const { candidate, key } of candidates) {
|
|
114
|
+
if (!filesByKey[key]) filesByKey[key] = [];
|
|
115
|
+
const list = filesByKey[key];
|
|
116
|
+
if (!list.includes(candidate.file)) list.push(candidate.file);
|
|
117
|
+
|
|
118
|
+
const existing = winners[key];
|
|
119
|
+
winners[key] = existing ? pickWinner(existing, candidate) : candidate;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const collisions: CollisionRecord[] = [];
|
|
123
|
+
for (const [key, files] of Object.entries(filesByKey)) {
|
|
124
|
+
if (files.length < 2) continue;
|
|
125
|
+
collisions.push({ key, files, winner: winners[key].file });
|
|
126
|
+
}
|
|
127
|
+
return { winners, collisions };
|
|
128
|
+
}
|