@decocms/start 2.28.0 → 2.28.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/start",
3
- "version": "2.28.0",
3
+ "version": "2.28.2",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -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
- // Deduplicate: when multiple files decode to the same key, prefer the one
66
- // with actual content (largest file size wins over empty {} stubs).
67
- const blockFiles: Record<string, string> = {};
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 = decodeBlockName(file);
70
- if (blockFiles[name]) {
71
- const existingSize = fs.statSync(path.join(blocksDir, blockFiles[name])).size;
72
- const newSize = fs.statSync(path.join(blocksDir, file)).size;
73
- if (newSize > existingSize) {
74
- blockFiles[name] = file;
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
- blockFiles[name] = file;
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 blocks: Record<string, unknown> = {};
82
- for (const [name, file] of Object.entries(blockFiles)) {
83
- try {
84
- const content = fs.readFileSync(path.join(blocksDir, file), "utf-8");
85
- blocks[name] = JSON.parse(content);
86
- } catch (e) {
87
- console.warn(`Failed to parse ${file}:`, e);
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
+ }
@@ -147,10 +147,13 @@ export function decoVitePlugin() {
147
147
  });
148
148
 
149
149
  // Tunnel + daemon: connect local dev to admin.deco.cx
150
- // Activated when DECO_SITE_NAME is set (e.g. DECO_SITE_NAME=mysite vite dev)
150
+ // Activated only when both DECO_SITE_NAME and DECO_ENV_NAME are set.
151
+ // Omitting DECO_ENV_NAME runs Vite fully local (no tunnel registration),
152
+ // since DECO_SITE_NAME alone is also consumed by site builds via vite's
153
+ // `define` for `process.env.DECO_SITE_NAME` and shouldn't force a tunnel.
151
154
  const siteName = process.env.DECO_SITE_NAME;
152
- if (siteName) {
153
- const envName = process.env.DECO_ENV_NAME || "dev";
155
+ const envName = process.env.DECO_ENV_NAME;
156
+ if (siteName && envName) {
154
157
 
155
158
  // Daemon files are .ts and live inside node_modules. Node's
156
159
  // experimental strip-types refuses to transpile node_modules, so