@decocms/blocks-migrate 0.0.0-stage → 8.1.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/src/vendor.ts ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Vendoring (spec: renames-and-migrations › Loaders, actions and invoke). In
3
+ * the next major the apps ship no loaders or actions, so the ones a site's
4
+ * content uses are copied into the site, from the v7 app package it has
5
+ * installed, with every module they import from that same package.
6
+ *
7
+ * A v7 type name `<app>/<path>` lives in `@decocms/apps-<app>/src/<path>`
8
+ * (7.x) or `@decocms/apps/<app>/<path>` (6.x). Either way it is copied to
9
+ * `src/vendor/<app>/<path>`, so relative imports inside the copy keep
10
+ * working, and imports of the package by name are rewritten to relative ones.
11
+ * Imports of other packages stay as they are; the import report lists them.
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import type { Report } from "./report";
16
+
17
+ const SOURCE_EXTENSIONS = [".ts", ".tsx"];
18
+ const VENDOR_DIR = path.join("src", "vendor");
19
+
20
+ /** `.tsx?` is optional in v7 type names (`shopify/loaders/ProductList` and `…/ProductList.ts`). */
21
+ function withExtensions(file: string): string[] {
22
+ return /\.(tsx?|jsx?)$/.test(file)
23
+ ? [file]
24
+ : [
25
+ ...SOURCE_EXTENSIONS.map((e) => file + e),
26
+ ...SOURCE_EXTENSIONS.map((e) => path.join(file, `index${e}`)),
27
+ ];
28
+ }
29
+
30
+ function isFile(file: string): boolean {
31
+ try {
32
+ return fs.statSync(file).isFile();
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /** The first candidate that is a file. */
39
+ export function firstFile(candidates: string[]): string | undefined {
40
+ return candidates.find(isFile);
41
+ }
42
+
43
+ interface PackageRef {
44
+ /** The package folder in node_modules. */
45
+ dir: string;
46
+ /** `exports` of its package.json. */
47
+ exports: Record<string, string>;
48
+ }
49
+
50
+ function readPackage(root: string, name: string): PackageRef | undefined {
51
+ const dir = path.join(root, "node_modules", name);
52
+ try {
53
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
54
+ const exports: Record<string, string> = {};
55
+ for (const [key, target] of Object.entries(manifest.exports ?? {})) {
56
+ if (typeof target === "string") exports[key] = target;
57
+ }
58
+ if (!exports["."] && typeof manifest.main === "string") exports["."] = manifest.main;
59
+ return { dir: fs.realpathSync(dir), exports };
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /** A subpath through a package's `exports` (exact keys and one-`*` patterns). */
66
+ function matchExports(pkg: PackageRef, subpath: string): string | undefined {
67
+ const exact = pkg.exports[subpath];
68
+ if (exact) return firstFile([path.join(pkg.dir, exact)]);
69
+ for (const [key, target] of Object.entries(pkg.exports)) {
70
+ const star = key.indexOf("*");
71
+ if (star < 0) continue;
72
+ const [prefix, suffix] = [key.slice(0, star), key.slice(star + 1)];
73
+ if (
74
+ subpath.startsWith(prefix) &&
75
+ subpath.endsWith(suffix) &&
76
+ subpath.length >= key.length - 1
77
+ ) {
78
+ const middle = subpath.slice(prefix.length, subpath.length - suffix.length);
79
+ const file = firstFile([path.join(pkg.dir, target.replace("*", middle))]);
80
+ if (file) return file;
81
+ }
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ function splitSpecifier(specifier: string): { name: string; subpath: string } | undefined {
87
+ const parts = specifier.split("/");
88
+ if (specifier.startsWith("@") ? parts.length < 2 : parts.length < 1) return undefined;
89
+ const count = specifier.startsWith("@") ? 2 : 1;
90
+ const rest = parts.slice(count).join("/");
91
+ return { name: parts.slice(0, count).join("/"), subpath: rest ? `./${rest}` : "." };
92
+ }
93
+
94
+ /** The file a bare specifier names in the site's node_modules, through the package's `exports`. */
95
+ export function resolvePackageFile(root: string, specifier: string): string | undefined {
96
+ const parts = splitSpecifier(specifier);
97
+ if (!parts) return undefined;
98
+ const pkg = readPackage(root, parts.name);
99
+ return pkg ? matchExports(pkg, parts.subpath) : undefined;
100
+ }
101
+
102
+ /** Where one app's sources sit, and where its copies go. */
103
+ interface AppSource {
104
+ /** The package's source root: copies keep their path relative to it. */
105
+ base: string;
106
+ /** The folder a v7 type name's `<path>` starts in. */
107
+ dir: string;
108
+ /** The package name; its own imports by name are rewritten to the copies. */
109
+ packageName: string;
110
+ /** Where the copies go: `src/vendor/<app>` (7.x) or `src/vendor` (6.x, a folder per app). */
111
+ dest: string;
112
+ }
113
+
114
+ function appSources(root: string, app: string): AppSource[] {
115
+ const sources: AppSource[] = [];
116
+ const split = readPackage(root, `@decocms/apps-${app}`); // 7.x: one package per app
117
+ if (split) {
118
+ const base = path.join(split.dir, "src");
119
+ sources.push({
120
+ base,
121
+ dir: base,
122
+ packageName: `@decocms/apps-${app}`,
123
+ dest: path.join(root, VENDOR_DIR, app),
124
+ });
125
+ }
126
+ const single = readPackage(root, "@decocms/apps"); // 6.x: one package, a folder per app
127
+ if (single) {
128
+ sources.push({
129
+ base: single.dir,
130
+ dir: path.join(single.dir, app),
131
+ packageName: "@decocms/apps",
132
+ dest: path.join(root, VENDOR_DIR),
133
+ });
134
+ }
135
+ return sources;
136
+ }
137
+
138
+ interface AppModule {
139
+ file: string;
140
+ app: AppSource;
141
+ }
142
+
143
+ /** The installed v7 source of a type name like `shopify/loaders/ProductList.ts`, if any. */
144
+ export function locateAppModule(root: string, type: string): AppModule | undefined {
145
+ const [app, ...rest] = type.split("/");
146
+ if (!app || rest.length === 0) return undefined;
147
+ for (const source of appSources(root, app)) {
148
+ const file = firstFile(withExtensions(path.join(source.dir, ...rest)));
149
+ // A type name is content, which editors control: never leave the package.
150
+ if (file && isInside(source.base, file)) return { file, app: source };
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ const IMPORT_SPECIFIER =
156
+ /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\bexport\s+\*\s+from\s*)(["'])([^"']+)\2/g;
157
+
158
+ function isInside(dir: string, file: string): boolean {
159
+ const rel = path.relative(dir, file);
160
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
161
+ }
162
+
163
+ /** An import specifier from one file to another, without extension or `/index`. */
164
+ export function relativeSpecifier(fromFile: string, toFile: string): string {
165
+ let rel = path.relative(path.dirname(fromFile), toFile).split(path.sep).join("/");
166
+ rel = rel.replace(/(\/index)?\.tsx?$/, "");
167
+ return rel.startsWith(".") ? rel : `./${rel}`;
168
+ }
169
+
170
+ /**
171
+ * Copy `entry` and the modules of its package it imports into the site, and
172
+ * return the copy of `entry`. `copies` maps each installed source to its copy
173
+ * and is shared across calls, so a module is copied once. A copy that exists
174
+ * is kept, so a second run never overwrites edits.
175
+ */
176
+ export function vendorModule(
177
+ root: string,
178
+ entry: AppModule,
179
+ copies: Map<string, string>,
180
+ report: Report,
181
+ ): string {
182
+ const { app } = entry;
183
+ const destOf = (source: string) => path.join(app.dest, path.relative(app.base, source));
184
+ const queue = [entry.file];
185
+
186
+ while (queue.length > 0) {
187
+ const source = queue.shift()!;
188
+ if (copies.has(source)) continue;
189
+ const dest = destOf(source);
190
+ copies.set(source, dest);
191
+ let text = fs.readFileSync(source, "utf8");
192
+ text = text.replace(
193
+ IMPORT_SPECIFIER,
194
+ (match, lead: string, quote: string, specifier: string) => {
195
+ let target: string | undefined;
196
+ if (specifier.startsWith(".")) {
197
+ target = firstFile(
198
+ withExtensions(path.resolve(path.dirname(source), specifier.replace(/\.js$/, ""))),
199
+ );
200
+ } else if (specifier === app.packageName || specifier.startsWith(`${app.packageName}/`)) {
201
+ target = resolvePackageFile(root, specifier);
202
+ } else {
203
+ return match;
204
+ }
205
+ if (!target) {
206
+ const asset = specifier.startsWith(".")
207
+ ? path.resolve(path.dirname(source), specifier)
208
+ : undefined;
209
+ if (asset && isInside(app.base, asset) && isFile(asset)) {
210
+ // A JSON or other non-TS file: copied as it is, same relative path.
211
+ const assetDest = destOf(asset);
212
+ if (!fs.existsSync(assetDest)) {
213
+ fs.mkdirSync(path.dirname(assetDest), { recursive: true });
214
+ fs.copyFileSync(asset, assetDest);
215
+ }
216
+ } else {
217
+ report.manual.push({
218
+ step: "vendor",
219
+ subject: path.relative(root, dest),
220
+ message: `imports ${specifier}, which doesn't resolve to a file; fix the import`,
221
+ });
222
+ }
223
+ return match;
224
+ }
225
+ if (!isInside(app.base, target)) {
226
+ report.manual.push({
227
+ step: "vendor",
228
+ subject: path.relative(root, dest),
229
+ message: `imports ${specifier}, outside ${app.packageName}; fix the import`,
230
+ });
231
+ return match;
232
+ }
233
+ queue.push(target);
234
+ return specifier.startsWith(".")
235
+ ? match
236
+ : `${lead}${quote}${relativeSpecifier(dest, destOf(target))}${quote}`;
237
+ },
238
+ );
239
+ if (!fs.existsSync(dest)) {
240
+ const origin = path.relative(app.base, source).split(path.sep).join("/");
241
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
242
+ fs.writeFileSync(
243
+ dest,
244
+ `// Vendored from ${app.packageName} (${origin}) by @decocms/blocks-migrate. It's your code now.\n${text}`,
245
+ );
246
+ }
247
+ }
248
+ return destOf(entry.file);
249
+ }
package/src/walk.ts ADDED
@@ -0,0 +1,30 @@
1
+ /** Saved-block helpers shared by the steps. */
2
+
3
+ import path from "node:path";
4
+ import { readSavedBlocks, type SavedBlocks } from "@decocms/blocks/cli";
5
+
6
+ export type JsonObject = Record<string, unknown>;
7
+
8
+ export function isPlainObject(value: unknown): value is JsonObject {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+
12
+ /** Calls `visit` on every object inside `value` that has a string `__resolveType`, outermost first. */
13
+ export function forEachBlock(
14
+ value: unknown,
15
+ visit: (block: JsonObject & { __resolveType: string }) => void,
16
+ ): void {
17
+ if (Array.isArray(value)) {
18
+ for (const item of value) forEachBlock(item, visit);
19
+ return;
20
+ }
21
+ if (!isPlainObject(value)) return;
22
+ if (typeof value.__resolveType === "string")
23
+ visit(value as JsonObject & { __resolveType: string });
24
+ for (const child of Object.values(value)) forEachBlock(child, visit);
25
+ }
26
+
27
+ /** The site's saved blocks, read by the same rule `deco content` uses. */
28
+ export function readContent(root: string): SavedBlocks {
29
+ return readSavedBlocks(path.join(root, ".deco", "blocks"));
30
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.