@decocms/blocks-migrate 0.0.0-stage → 8.0.0-next.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/bin/blocks-migrate.js +13 -0
- package/package.json +38 -4
- package/src/blockMap.ts +344 -0
- package/src/content.ts +121 -0
- package/src/experiments.ts +54 -0
- package/src/imports.ts +335 -0
- package/src/legacyNames.ts +45 -0
- package/src/main.ts +43 -0
- package/src/migrate.ts +88 -0
- package/src/report.ts +40 -0
- package/src/secrets.ts +117 -0
- package/src/vendor.ts +249 -0
- package/src/walk.ts +30 -0
- package/README.md +0 -3
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `blocks-migrate` command. Loads the TypeScript sources: Bun runs them
|
|
3
|
+
// as they are; under Node, tsx's loader is registered first (the same way
|
|
4
|
+
// `deco` ships in @decocms/blocks).
|
|
5
|
+
const entry = new URL("../src/main.ts", import.meta.url);
|
|
6
|
+
|
|
7
|
+
if (typeof globalThis.Bun !== "undefined") {
|
|
8
|
+
await import(entry.href);
|
|
9
|
+
} else {
|
|
10
|
+
const { register } = await import("tsx/esm/api");
|
|
11
|
+
register();
|
|
12
|
+
await import(entry.href);
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-migrate",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "8.0.0-next.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24"
|
|
7
|
+
},
|
|
8
|
+
"description": "One-time migration of a v7 Deco site to the next major: block map with legacy aliases, vendored loaders, content in .deco/blocks, secret re-encryption and an import report",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/decocms/blocks.git",
|
|
12
|
+
"directory": "packages/blocks-migrate"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"src",
|
|
17
|
+
"!src/**/*.test.ts"
|
|
18
|
+
],
|
|
19
|
+
"bin": {
|
|
20
|
+
"blocks-migrate": "./bin/blocks-migrate.js"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"test": "vitest run --root ../.. packages/blocks-migrate/",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"lint:unused": "knip"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@decocms/blocks": "8.0.0-next.0",
|
|
30
|
+
"tsx": "^4.22.5",
|
|
31
|
+
"typescript": "^5.9.3"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"knip": "^5.86.0"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"registry": "https://registry.npmjs.org",
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/blockMap.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The block map (spec: renames-and-migrations › Rename a block type). Saved
|
|
3
|
+
* content names v7 types by file path, like `site/sections/Product/Shelf.tsx`;
|
|
4
|
+
* the next major resolves a name only through `.deco/index.ts`. This step
|
|
5
|
+
* writes that file: one entry per type, under a short name, plus an alias
|
|
6
|
+
* under every v7 name the content stores, so the content keeps resolving.
|
|
7
|
+
*
|
|
8
|
+
* - The site's own modules (`site/…`) are registered from `src/`, every
|
|
9
|
+
* section under `src/sections` included, so editors can still add them.
|
|
10
|
+
* - App loaders and actions the content calls are vendored first (see
|
|
11
|
+
* ./vendor.ts) and registered from the copy.
|
|
12
|
+
* - Built-ins and the legacy names the runtime already maps (`page`,
|
|
13
|
+
* `multivariate`, …) need no entry.
|
|
14
|
+
* - Everything else has no v8 equivalent; it is reported, not registered.
|
|
15
|
+
*
|
|
16
|
+
* Sections return render descriptors, `{ component, props }`, and `page`
|
|
17
|
+
* takes them (see /next/tanstack-start-descriptors): it works in any
|
|
18
|
+
* framework, and the site's view registry maps each component name to the
|
|
19
|
+
* section it renders.
|
|
20
|
+
*/
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { BUILT_IN_BLOCKS, LEGACY_ALIASES } from "@decocms/blocks/cli";
|
|
24
|
+
import type { Report } from "./report";
|
|
25
|
+
import { firstFile, locateAppModule, vendorModule } from "./vendor";
|
|
26
|
+
import { forEachBlock, readContent } from "./walk";
|
|
27
|
+
|
|
28
|
+
type Kind = "section" | "function";
|
|
29
|
+
|
|
30
|
+
interface Entry {
|
|
31
|
+
/** The module that holds the block function (default export). */
|
|
32
|
+
file: string;
|
|
33
|
+
kind: Kind;
|
|
34
|
+
/** The v7 names content stores for it. */
|
|
35
|
+
legacy: string[];
|
|
36
|
+
short: string;
|
|
37
|
+
ident: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ANALYTICS_SECTION = /^website\/sections\/Analytics\//;
|
|
41
|
+
|
|
42
|
+
/** Where to look for a type without a v8 equivalent. */
|
|
43
|
+
const HINTS: [RegExp, string][] = [
|
|
44
|
+
[
|
|
45
|
+
/\/apps\//,
|
|
46
|
+
"a v7 app block; the next major has no apps: create the platform's client in code with these settings (/next/upstream-clients#call-a-client), then delete the saved block",
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
/^website\/sections\/Rendering\/(Lazy|Deferred|SingleDeferred)\.tsx?$/,
|
|
50
|
+
"v7's Lazy section wrapper: register a block under this name that returns its `section` (it renders normally), or unwrap it in the content",
|
|
51
|
+
],
|
|
52
|
+
[
|
|
53
|
+
/\/sections\/Seo\//,
|
|
54
|
+
"an SEO section: register your platform template's SEO block under this name (/next/renames-and-migrations#rename-a-type-with-an-alias)",
|
|
55
|
+
],
|
|
56
|
+
[
|
|
57
|
+
ANALYTICS_SECTION,
|
|
58
|
+
"v7's Analytics section: move its tag IDs into your template's tag-manager block (/next/renames-and-migrations#telemetry-and-analytics)",
|
|
59
|
+
],
|
|
60
|
+
[
|
|
61
|
+
/\/matchers\//,
|
|
62
|
+
"a v7 matcher with no built-in: register a matcher block under this name (/next/matchers-and-variants#matchers)",
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
/^website\/functions\/requestToParam/,
|
|
66
|
+
"reads the request; take route params from matchRoute's `params` instead (/next/api-reference#matchroute-url-items)",
|
|
67
|
+
],
|
|
68
|
+
];
|
|
69
|
+
const DEFAULT_HINT =
|
|
70
|
+
"no v8 equivalent and no installed source to vendor: register a block under this name in .deco/index.ts, or migrate the content";
|
|
71
|
+
|
|
72
|
+
function hintFor(type: string): string {
|
|
73
|
+
return HINTS.find(([pattern]) => pattern.test(type))?.[1] ?? DEFAULT_HINT;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function kebab(name: string): string {
|
|
77
|
+
return name
|
|
78
|
+
.replace(/\.(tsx?|jsx?)$/, "")
|
|
79
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
80
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
|
81
|
+
.replace(/[^A-Za-z0-9]+/g, "-")
|
|
82
|
+
.replace(/^-+|-+$/g, "")
|
|
83
|
+
.toLowerCase();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const RESERVED_WORDS = new Set(
|
|
87
|
+
`arguments await break case catch class const continue debugger default delete do else enum eval
|
|
88
|
+
export extends false finally for function if implements import in instanceof interface let new null
|
|
89
|
+
package private protected public return static super switch this throw true try typeof var void
|
|
90
|
+
while with yield`.split(/\s+/),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
function camel(name: string): string {
|
|
94
|
+
const ident = name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
95
|
+
if (/^[0-9]/.test(ident)) return `_${ident}`;
|
|
96
|
+
return RESERVED_WORDS.has(ident) ? `${ident}Block` : ident;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function moduleFile(base: string): string | undefined {
|
|
100
|
+
return /\.(tsx?)$/.test(base) ? firstFile([base]) : firstFile([`${base}.ts`, `${base}.tsx`]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function listFiles(dir: string, ext: RegExp): string[] {
|
|
104
|
+
if (!fs.existsSync(dir)) return [];
|
|
105
|
+
return fs
|
|
106
|
+
.readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
107
|
+
.filter((d) => d.isFile() && ext.test(d.name) && !/\.test\.tsx?$/.test(d.name))
|
|
108
|
+
.map((d) => path.join(d.parentPath, d.name));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const HAS_DEFAULT = /\bexport\s+default\b|\bexport\s*\{[^}]*\bdefault\b/;
|
|
112
|
+
const HAS_LOADER =
|
|
113
|
+
/\bexport\s+(?:async\s+)?(?:function|const|let)\s+loader\b|\bexport\s*\{[^}]*\bloader\b/;
|
|
114
|
+
|
|
115
|
+
function posix(p: string): string {
|
|
116
|
+
return p.split(path.sep).join("/");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function importPath(root: string, file: string): string {
|
|
120
|
+
const rel = posix(path.relative(path.join(root, ".deco"), file)).replace(/\.tsx?$/, "");
|
|
121
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface BlockMapResult {
|
|
125
|
+
/** Each vendored module: installed source → its copy in the site. */
|
|
126
|
+
vendored: Map<string, string>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Write `.deco/index.ts` from the site's content and sources. Keeps an existing block map. */
|
|
130
|
+
export function writeBlockMap(root: string, report: Report): BlockMapResult {
|
|
131
|
+
const existing = ["index.ts", "index.tsx"].find((f) =>
|
|
132
|
+
fs.existsSync(path.join(root, ".deco", f)),
|
|
133
|
+
);
|
|
134
|
+
if (existing) {
|
|
135
|
+
report.manual.push({
|
|
136
|
+
step: "block map",
|
|
137
|
+
subject: `.deco/${existing}`,
|
|
138
|
+
message: "already exists, so it was kept; delete it and run again to generate one",
|
|
139
|
+
});
|
|
140
|
+
return { vendored: new Map() };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const { blocks, files } = readContent(root);
|
|
144
|
+
const used = new Map<string, Set<string>>();
|
|
145
|
+
/** v7 Analytics sections' GTM/GA4 IDs, so the report carries them over. */
|
|
146
|
+
const tagIds = new Map<string, Set<string>>();
|
|
147
|
+
for (const [entry, block] of Object.entries(blocks)) {
|
|
148
|
+
forEachBlock(block, (node) => {
|
|
149
|
+
const type = node.__resolveType;
|
|
150
|
+
if (!used.has(type)) used.set(type, new Set());
|
|
151
|
+
used.get(type)!.add(files[entry]);
|
|
152
|
+
if (!ANALYTICS_SECTION.test(type)) return;
|
|
153
|
+
for (const field of ["trackingIds", "googleAnalyticsIds"]) {
|
|
154
|
+
const ids = node[field];
|
|
155
|
+
if (!Array.isArray(ids)) continue;
|
|
156
|
+
if (!tagIds.has(type)) tagIds.set(type, new Set());
|
|
157
|
+
for (const id of ids) if (typeof id === "string" && id) tagIds.get(type)!.add(id);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const builtIn = new Set(BUILT_IN_BLOCKS);
|
|
162
|
+
const types = new Set(
|
|
163
|
+
[...used.keys()].filter(
|
|
164
|
+
(t) => !Object.hasOwn(blocks, t) && !builtIn.has(t) && !Object.hasOwn(LEGACY_ALIASES, t),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
for (const file of listFiles(path.join(root, "src", "sections"), /\.tsx$/)) {
|
|
168
|
+
const type = `site/${posix(path.relative(path.join(root, "src"), file))}`;
|
|
169
|
+
if (!types.has(type) && !types.has(type.replace(/\.tsx$/, ""))) types.add(type);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const byFile = new Map<string, Entry>();
|
|
173
|
+
const vendored = new Map<string, string>();
|
|
174
|
+
const leave = (type: string, hint: string) => {
|
|
175
|
+
const ids = [...(tagIds.get(type) ?? [])];
|
|
176
|
+
const message = ids.length > 0 ? `${hint}; its tag IDs: ${ids.join(", ")}` : hint;
|
|
177
|
+
const where = [...(used.get(type) ?? [])];
|
|
178
|
+
const usedIn =
|
|
179
|
+
where.length > 0
|
|
180
|
+
? ` (in ${where.slice(0, 3).join(", ")}${where.length > 3 ? ", …" : ""})`
|
|
181
|
+
: "";
|
|
182
|
+
report.manual.push({ step: "block map", subject: `${type}${usedIn}`, message });
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
for (const type of [...types].sort()) {
|
|
186
|
+
const [namespace, group] = type.split("/");
|
|
187
|
+
const kind: Kind = group === "sections" ? "section" : "function";
|
|
188
|
+
let file: string | undefined;
|
|
189
|
+
if (namespace === "site") {
|
|
190
|
+
const rest = type.slice("site/".length);
|
|
191
|
+
if (rest.split("/").includes("..")) {
|
|
192
|
+
leave(
|
|
193
|
+
type,
|
|
194
|
+
"not a path inside src/; register a block under this name, or migrate the content",
|
|
195
|
+
);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (rest.startsWith("apps/")) {
|
|
199
|
+
leave(type, hintFor(type));
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
file = moduleFile(path.join(root, "src", rest));
|
|
203
|
+
if (!file) {
|
|
204
|
+
leave(
|
|
205
|
+
type,
|
|
206
|
+
`no src/${rest} in the site; register a block under this name, or migrate the content`,
|
|
207
|
+
);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
} else if (group === "loaders" || group === "actions") {
|
|
211
|
+
const located = locateAppModule(root, type);
|
|
212
|
+
if (!located) {
|
|
213
|
+
leave(type, hintFor(type));
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const before = vendored.size;
|
|
217
|
+
file = vendorModule(root, located, vendored, report);
|
|
218
|
+
const count = vendored.size - before;
|
|
219
|
+
report.done.push({
|
|
220
|
+
step: "vendor",
|
|
221
|
+
subject: type,
|
|
222
|
+
message: `copied to ${posix(path.relative(root, file))}${count > 1 ? ` with ${count - 1} more files` : ""}; rewrite it over the platform's upstream client (/next/upstream-clients)`,
|
|
223
|
+
});
|
|
224
|
+
} else {
|
|
225
|
+
leave(type, hintFor(type));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const source = fs.readFileSync(file, "utf8");
|
|
230
|
+
if (!HAS_DEFAULT.test(source)) {
|
|
231
|
+
leave(type, `${posix(path.relative(root, file))} has no default export to register`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const entry = byFile.get(file);
|
|
235
|
+
if (entry) {
|
|
236
|
+
entry.legacy.push(type);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
byFile.set(file, { file, kind, legacy: [type], short: "", ident: "" });
|
|
240
|
+
if (kind === "section" && HAS_LOADER.test(source)) {
|
|
241
|
+
leave(
|
|
242
|
+
type,
|
|
243
|
+
"the section has a loader: its saved props are the loader's input (as in v7), but nothing runs the loader; move that code into the block function or a server function",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (group === "actions") {
|
|
247
|
+
leave(
|
|
248
|
+
type,
|
|
249
|
+
"an action: block functions should only read; call it from a server function or route handler instead (/next/renames-and-migrations#loaders-actions-and-invoke)",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Short names: the file's name, then its path, then a number; never a
|
|
255
|
+
// built-in, a saved block's name or a v7 name.
|
|
256
|
+
const taken = new Set<string>([...builtIn, ...Object.keys(blocks), ...types]);
|
|
257
|
+
const idents = new Set<string>(["section", "PropsOf"]);
|
|
258
|
+
const entries = [...byFile.values()];
|
|
259
|
+
for (const entry of entries) {
|
|
260
|
+
const type = entry.legacy[0];
|
|
261
|
+
const segments = type.split("/");
|
|
262
|
+
const prefix = segments[0] === "site" ? "" : `${segments[0]}-`;
|
|
263
|
+
const candidates = [
|
|
264
|
+
prefix + kebab(segments.at(-1)!),
|
|
265
|
+
prefix + kebab(segments.slice(2).join("-")),
|
|
266
|
+
];
|
|
267
|
+
let short = candidates.find((c) => c && !taken.has(c));
|
|
268
|
+
for (let n = 2; !short; n++)
|
|
269
|
+
if (!taken.has(`${candidates[1]}-${n}`)) short = `${candidates[1]}-${n}`;
|
|
270
|
+
taken.add(short);
|
|
271
|
+
entry.short = short;
|
|
272
|
+
let ident = camel(short);
|
|
273
|
+
for (let n = 2; idents.has(ident); n++) ident = `${camel(short)}${n}`;
|
|
274
|
+
idents.add(ident);
|
|
275
|
+
entry.ident = ident;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
fs.writeFileSync(path.join(root, ".deco", "index.ts"), renderBlockMap(root, entries));
|
|
279
|
+
const aliases = entries.reduce((n, e) => n + e.legacy.length, 0);
|
|
280
|
+
report.done.push({
|
|
281
|
+
step: "block map",
|
|
282
|
+
subject: ".deco/index.ts",
|
|
283
|
+
message: `${entries.length} blocks, with ${aliases} aliases under their v7 names`,
|
|
284
|
+
});
|
|
285
|
+
if (entries.some((e) => e.kind === "section")) {
|
|
286
|
+
report.manual.push({
|
|
287
|
+
step: "block map",
|
|
288
|
+
subject: ".deco/index.ts",
|
|
289
|
+
message:
|
|
290
|
+
"sections return descriptors ({ component, props }): add a view registry that maps each component name to its section (/next/rendering)",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return { vendored };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function renderBlockMap(root: string, entries: Entry[]): string {
|
|
297
|
+
const lines = [
|
|
298
|
+
"// Generated by @decocms/blocks-migrate from a v7 site. It's your code now: edit it freely.",
|
|
299
|
+
"// Each block has a short name plus an alias under every v7 name saved content stores",
|
|
300
|
+
"// (see /next/renames-and-migrations).",
|
|
301
|
+
'import type { Blocks, Route, Seo } from "@decocms/blocks";',
|
|
302
|
+
];
|
|
303
|
+
for (const e of entries.filter((e) => e.kind === "function")) {
|
|
304
|
+
lines.push(`import ${e.ident} from "${importPath(root, e.file)}";`);
|
|
305
|
+
}
|
|
306
|
+
lines.push(
|
|
307
|
+
"",
|
|
308
|
+
"/** What a section block returns: the component to render and its props (/next/rendering). */",
|
|
309
|
+
"export interface BlockDescriptor {",
|
|
310
|
+
" component: string;",
|
|
311
|
+
" props: unknown;",
|
|
312
|
+
"}",
|
|
313
|
+
"",
|
|
314
|
+
"/** A page with its blocks resolved. */",
|
|
315
|
+
"export interface ResolvedPage extends Route {",
|
|
316
|
+
" seo?: Seo;",
|
|
317
|
+
" sections: BlockDescriptor[];",
|
|
318
|
+
"}",
|
|
319
|
+
"",
|
|
320
|
+
"/** A v7 section's saved props: its loader's input when it has one, else its component's props. */",
|
|
321
|
+
"type PropsOf<M> = M extends { loader: (props: infer P, ...rest: any[]) => unknown }",
|
|
322
|
+
" ? P",
|
|
323
|
+
" : M extends { default: (props: infer P, ...rest: any[]) => unknown }",
|
|
324
|
+
" ? P",
|
|
325
|
+
" : Record<string, never>;",
|
|
326
|
+
"",
|
|
327
|
+
"const section =",
|
|
328
|
+
" <M>(component: string) =>",
|
|
329
|
+
" (props: PropsOf<M>): BlockDescriptor => ({ component, props });",
|
|
330
|
+
"",
|
|
331
|
+
);
|
|
332
|
+
for (const e of entries.filter((e) => e.kind === "section")) {
|
|
333
|
+
lines.push(
|
|
334
|
+
`const ${e.ident} = section<typeof import("${importPath(root, e.file)}")>(${JSON.stringify(e.short)});`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
lines.push("", "export default {", " page: (input: ResolvedPage) => input,");
|
|
338
|
+
for (const e of entries) {
|
|
339
|
+
lines.push(` ${JSON.stringify(e.short)}: ${e.ident},`);
|
|
340
|
+
for (const legacy of e.legacy) lines.push(` ${JSON.stringify(legacy)}: ${e.ident},`);
|
|
341
|
+
}
|
|
342
|
+
lines.push("} satisfies Blocks;", "");
|
|
343
|
+
return lines.join("\n");
|
|
344
|
+
}
|
package/src/content.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content: the next major reads saved blocks only from `.deco/blocks`, one
|
|
3
|
+
* JSON file per entry, named by the protocol's file-name rule
|
|
4
|
+
* (`@decocms/blocks/protocol/keys`). A v7 site that has them there keeps
|
|
5
|
+
* them as they are; one that only has a decofile (the `/.decofile` dump, or
|
|
6
|
+
* the generated `.deco/blocks.gen.json`) gets it split into files. The v7
|
|
7
|
+
* generated files in `.deco/` go: `deco schema` and `deco content` write
|
|
8
|
+
* their replacements.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { blockFileName, checkBlockName, serializeBlock } from "@decocms/blocks/protocol/keys";
|
|
13
|
+
import type { Report } from "./report";
|
|
14
|
+
import { isPlainObject, readContent } from "./walk";
|
|
15
|
+
|
|
16
|
+
/** Generated by the v7 codegen; nothing in the next major reads them. */
|
|
17
|
+
const V7_GENERATED = [
|
|
18
|
+
"blocks.gen.json",
|
|
19
|
+
"blocks.gen.ts",
|
|
20
|
+
"loaders.gen.ts",
|
|
21
|
+
"sections.gen.ts",
|
|
22
|
+
"meta.gen.json",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Where a v7 site may keep a whole decofile, tried in order. */
|
|
26
|
+
const DECOFILE_CANDIDATES = [".deco/blocks.gen.json", ".decofile.json", ".decofile"];
|
|
27
|
+
|
|
28
|
+
function hasJsonFiles(dir: string): boolean {
|
|
29
|
+
try {
|
|
30
|
+
return fs.readdirSync(dir).some((f) => f.endsWith(".json"));
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The entries of a decofile: a map of entry name to block, either at the top
|
|
38
|
+
* level or under `blocks` (the shape `/.decofile` serves).
|
|
39
|
+
*/
|
|
40
|
+
export function decofileEntries(decofile: unknown): Record<string, Record<string, unknown>> {
|
|
41
|
+
if (!isPlainObject(decofile)) return {};
|
|
42
|
+
const values = Object.values(decofile);
|
|
43
|
+
const flat = values.length > 0 && values.every((v) => isPlainObject(v) && "__resolveType" in v);
|
|
44
|
+
const map = flat ? decofile : isPlainObject(decofile.blocks) ? decofile.blocks : {};
|
|
45
|
+
const entries: Record<string, Record<string, unknown>> = {};
|
|
46
|
+
for (const [name, block] of Object.entries(map)) {
|
|
47
|
+
if (isPlainObject(block)) entries[name] = block;
|
|
48
|
+
}
|
|
49
|
+
return entries;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface MoveContentOptions {
|
|
53
|
+
/** A decofile to split into `.deco/blocks`, relative to the root. */
|
|
54
|
+
decofile?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function moveContent(root: string, report: Report, options: MoveContentOptions = {}): void {
|
|
58
|
+
const blocksDir = path.join(root, ".deco", "blocks");
|
|
59
|
+
const hasContent = hasJsonFiles(blocksDir);
|
|
60
|
+
if (options.decofile && hasContent) {
|
|
61
|
+
throw new Error(`--decofile given but .deco/blocks already has content`);
|
|
62
|
+
}
|
|
63
|
+
if (!hasContent) {
|
|
64
|
+
const candidates = options.decofile ? [options.decofile] : DECOFILE_CANDIDATES;
|
|
65
|
+
const source = candidates.map((c) => path.resolve(root, c)).find((c) => fs.existsSync(c));
|
|
66
|
+
if (source) {
|
|
67
|
+
const entries = decofileEntries(JSON.parse(fs.readFileSync(source, "utf8")));
|
|
68
|
+
fs.mkdirSync(blocksDir, { recursive: true });
|
|
69
|
+
let written = 0;
|
|
70
|
+
for (const [name, block] of Object.entries(entries)) {
|
|
71
|
+
const [invalid] = checkBlockName(name);
|
|
72
|
+
if (invalid) {
|
|
73
|
+
report.manual.push({
|
|
74
|
+
step: "content",
|
|
75
|
+
subject: name,
|
|
76
|
+
message: `not a valid entry name (${invalid.message}); save it under another name`,
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
fs.writeFileSync(path.join(blocksDir, blockFileName(name)), serializeBlock(block));
|
|
81
|
+
written++;
|
|
82
|
+
}
|
|
83
|
+
report.done.push({
|
|
84
|
+
step: "content",
|
|
85
|
+
subject: path.relative(root, source),
|
|
86
|
+
message: `split into ${written} files in .deco/blocks`,
|
|
87
|
+
});
|
|
88
|
+
} else if (options.decofile) {
|
|
89
|
+
throw new Error(`no decofile at ${options.decofile}`);
|
|
90
|
+
} else {
|
|
91
|
+
report.manual.push({
|
|
92
|
+
step: "content",
|
|
93
|
+
subject: ".deco/blocks",
|
|
94
|
+
message:
|
|
95
|
+
"no saved blocks found; download the live content (GET <site>/.decofile) and pass it with --decofile",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Names the file-name rule rejects (v7 saved editor previews like
|
|
101
|
+
// "Preview site/sections/Hero.tsx"): deco content stops on them.
|
|
102
|
+
for (const d of readContent(root).diagnostics) {
|
|
103
|
+
if (d.severity !== "error") continue;
|
|
104
|
+
report.manual.push({
|
|
105
|
+
step: "content",
|
|
106
|
+
subject: `.deco/blocks/${d.file}`,
|
|
107
|
+
message: `${d.message}, or delete it if nothing references it`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const file of V7_GENERATED) {
|
|
112
|
+
const full = path.join(root, ".deco", file);
|
|
113
|
+
if (!fs.existsSync(full)) continue;
|
|
114
|
+
fs.rmSync(full);
|
|
115
|
+
report.done.push({
|
|
116
|
+
step: "content",
|
|
117
|
+
subject: `.deco/${file}`,
|
|
118
|
+
message: "removed (v7 generated; deco schema and deco content write the next major's files)",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Experiment IDs (spec: renames-and-migrations › Telemetry and analytics).
|
|
3
|
+
* v7 keyed A/B results on the saved name of the random matcher a variant's
|
|
4
|
+
* rule pointed at. The next major keys them on `multivariate`'s `experiment`,
|
|
5
|
+
* so this step copies that name into it and results carry over.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { LEGACY_ALIASES } from "@decocms/blocks/cli";
|
|
10
|
+
import { serializeBlock } from "@decocms/blocks/protocol/keys";
|
|
11
|
+
import type { Report } from "./report";
|
|
12
|
+
import { forEachBlock, readContent } from "./walk";
|
|
13
|
+
|
|
14
|
+
const RANDOM_MATCHERS = new Set(["website/matchers/random.ts", "website/matchers/random"]);
|
|
15
|
+
|
|
16
|
+
function isMultivariate(type: string): boolean {
|
|
17
|
+
return (
|
|
18
|
+
type === "multivariate" ||
|
|
19
|
+
(Object.hasOwn(LEGACY_ALIASES, type) && LEGACY_ALIASES[type] === "multivariate")
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Set `experiment` on every `multivariate` whose variant rule is a saved random matcher. */
|
|
24
|
+
export function copyExperimentIds(root: string, report: Report): void {
|
|
25
|
+
const { blocks, files } = readContent(root);
|
|
26
|
+
const randoms = new Set(
|
|
27
|
+
Object.entries(blocks)
|
|
28
|
+
.filter(([, b]) => RANDOM_MATCHERS.has(b.__resolveType as string))
|
|
29
|
+
.map(([name]) => name),
|
|
30
|
+
);
|
|
31
|
+
if (randoms.size === 0) return;
|
|
32
|
+
|
|
33
|
+
for (const [entry, block] of Object.entries(blocks)) {
|
|
34
|
+
let changed = false;
|
|
35
|
+
forEachBlock(block, (node) => {
|
|
36
|
+
if (!isMultivariate(node.__resolveType) || node.experiment !== undefined) return;
|
|
37
|
+
const variants = Array.isArray(node.variants) ? node.variants : [];
|
|
38
|
+
const name = variants
|
|
39
|
+
.map((v) => (v as { rule?: { __resolveType?: unknown } } | null)?.rule?.__resolveType)
|
|
40
|
+
.find((t): t is string => typeof t === "string" && randoms.has(t));
|
|
41
|
+
if (!name) return;
|
|
42
|
+
node.experiment = name;
|
|
43
|
+
changed = true;
|
|
44
|
+
report.done.push({
|
|
45
|
+
step: "content",
|
|
46
|
+
subject: files[entry],
|
|
47
|
+
message: `set experiment "${name}" (the random matcher's saved name), so A/B results carry over`,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
if (changed) {
|
|
51
|
+
fs.writeFileSync(path.join(root, ".deco", "blocks", files[entry]), serializeBlock(block));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|