@decocms/start 6.4.3 → 6.4.5
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/migrate/phase-analyze.test.ts +63 -0
- package/scripts/migrate/phase-analyze.ts +15 -2
- package/scripts/migrate/phase-verify.test.ts +127 -0
- package/scripts/migrate/phase-verify.ts +44 -4
- package/scripts/migrate/types.ts +1 -0
- package/src/cms/loader.test.ts +20 -0
- package/src/cms/loader.ts +26 -15
package/package.json
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { extractPlatform } from "./phase-analyze";
|
|
6
|
+
|
|
7
|
+
// `extractPlatform` runs four strategies in order:
|
|
8
|
+
// 1. deno.json imports referencing apps/{platform}/
|
|
9
|
+
// 2. apps/{platform}.ts file existence
|
|
10
|
+
// 3. apps/site.ts loose string match against platform name + "platform"
|
|
11
|
+
// 4. .deco/blocks filenames hinting at platform
|
|
12
|
+
// Strategy 3 is the false-positive trap that ate Magento sites before #211:
|
|
13
|
+
// helsinki/granadobr's apps/site.ts imports `apps/vtex/mod.ts` for the color
|
|
14
|
+
// palette, so the content matches `"vtex"` even though the real platform is
|
|
15
|
+
// declared via `apps/magento.ts`. The fix adds "magento" to the platforms
|
|
16
|
+
// list so Strategy 2 fires first and short-circuits before Strategy 3 can
|
|
17
|
+
// over-match.
|
|
18
|
+
|
|
19
|
+
describe("extractPlatform", () => {
|
|
20
|
+
let tmp: string;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "extract-platform-"));
|
|
24
|
+
fs.mkdirSync(path.join(tmp, "apps"), { recursive: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("detects magento from apps/magento.ts (granadobr/helsinki shape)", () => {
|
|
32
|
+
fs.writeFileSync(path.join(tmp, "apps", "magento.ts"), "export default {};\n");
|
|
33
|
+
// Also write a site.ts that imports vtex for color palettes — this is
|
|
34
|
+
// what tripped Strategy 3 before #211.
|
|
35
|
+
fs.writeFileSync(
|
|
36
|
+
path.join(tmp, "apps", "site.ts"),
|
|
37
|
+
`import { color as vtex } from "apps/vtex/mod.ts";\n` +
|
|
38
|
+
`export interface State { platform: string }\n`,
|
|
39
|
+
);
|
|
40
|
+
expect(extractPlatform(tmp)).toBe("magento");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("detects vtex from apps/vtex.ts", () => {
|
|
44
|
+
fs.writeFileSync(path.join(tmp, "apps", "vtex.ts"), "export default { account: \"foo\" };\n");
|
|
45
|
+
expect(extractPlatform(tmp)).toBe("vtex");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("falls back to custom when no platform signal exists", () => {
|
|
49
|
+
fs.writeFileSync(path.join(tmp, "apps", "site.ts"), "export default {};\n");
|
|
50
|
+
expect(extractPlatform(tmp)).toBe("custom");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("prefers magento over vtex when both an apps/magento.ts AND a vtex string match exist", () => {
|
|
54
|
+
// Reproduce the exact granadobr trap: apps/magento.ts is the real signal,
|
|
55
|
+
// but apps/site.ts loosely contains "vtex" + "platform".
|
|
56
|
+
fs.writeFileSync(path.join(tmp, "apps", "magento.ts"), "export default {};\n");
|
|
57
|
+
fs.writeFileSync(
|
|
58
|
+
path.join(tmp, "apps", "site.ts"),
|
|
59
|
+
`import "apps/vtex/mod.ts";\nexport interface State { platform: string }\n`,
|
|
60
|
+
);
|
|
61
|
+
expect(extractPlatform(tmp)).toBe("magento");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -467,8 +467,21 @@ function extractVtexAccount(sourceDir: string): string | null {
|
|
|
467
467
|
return null;
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
-
function extractPlatform(sourceDir: string): Platform {
|
|
471
|
-
|
|
470
|
+
export function extractPlatform(sourceDir: string): Platform {
|
|
471
|
+
// Strategy 2 (apps/{p}.ts existence) is the most reliable signal, but it
|
|
472
|
+
// only matches when the platform name appears here — so a site whose only
|
|
473
|
+
// app file is `apps/magento.ts` previously fell through to Strategy 3
|
|
474
|
+
// and got mis-detected as "vtex" because site.ts imports
|
|
475
|
+
// `apps/vtex/mod.ts` for color palettes. See #211.
|
|
476
|
+
const platforms: Platform[] = [
|
|
477
|
+
"vtex",
|
|
478
|
+
"shopify",
|
|
479
|
+
"wake",
|
|
480
|
+
"vnda",
|
|
481
|
+
"linx",
|
|
482
|
+
"nuvemshop",
|
|
483
|
+
"magento",
|
|
484
|
+
];
|
|
472
485
|
|
|
473
486
|
// Strategy 1: Check deno.json imports for platform-specific app imports
|
|
474
487
|
const denoPath = path.join(sourceDir, "deno.json");
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { checks } from "./phase-verify";
|
|
6
|
+
import type { MigrationContext } from "./types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The "No relative imports to deleted SDK files" check used to flag any
|
|
10
|
+
* relative import to `sdk/{clx,useId,useOffer,useVariantPossiblities,
|
|
11
|
+
* usePlatform}`. But `useOffer` and `useVariantPossiblities` are KEPT as
|
|
12
|
+
* site files (see RELATIVE_SDK_REWRITES in transforms/imports.ts) — so
|
|
13
|
+
* every site that legitimately imports them via a relative path was
|
|
14
|
+
* failing verify with a misleading error. See #212.
|
|
15
|
+
*
|
|
16
|
+
* Pull the specific check out of the registered array and exercise it on
|
|
17
|
+
* a fixture tree, so we don't have to satisfy the other 24 checks that
|
|
18
|
+
* `verify()` runs at once.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
function makeCtx(sourceDir: string): MigrationContext {
|
|
22
|
+
return {
|
|
23
|
+
sourceDir,
|
|
24
|
+
siteName: "test-site",
|
|
25
|
+
platform: "custom",
|
|
26
|
+
vtexAccount: null,
|
|
27
|
+
gtmId: null,
|
|
28
|
+
importMap: {},
|
|
29
|
+
discoveredNpmDeps: {},
|
|
30
|
+
themeColors: {},
|
|
31
|
+
fontFamily: null,
|
|
32
|
+
files: [],
|
|
33
|
+
sectionMetas: [],
|
|
34
|
+
islandClassifications: [],
|
|
35
|
+
islandWrapperTargets: new Map(),
|
|
36
|
+
loaderInventory: [],
|
|
37
|
+
scaffoldedFiles: [],
|
|
38
|
+
transformedFiles: [],
|
|
39
|
+
deletedFiles: [],
|
|
40
|
+
movedFiles: [],
|
|
41
|
+
manualReviewItems: [],
|
|
42
|
+
frameworkFindings: [],
|
|
43
|
+
dryRun: false,
|
|
44
|
+
verbose: false,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sdkCheck = checks.find(
|
|
49
|
+
(c) => c.name === "No relative imports to deleted SDK files",
|
|
50
|
+
);
|
|
51
|
+
if (!sdkCheck) {
|
|
52
|
+
throw new Error("verify check not found — name changed?");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runCheck(ctx: MigrationContext): { ok: boolean; output: string } {
|
|
56
|
+
const lines: string[] = [];
|
|
57
|
+
const spy = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
|
|
58
|
+
lines.push(args.map((a) => String(a)).join(" "));
|
|
59
|
+
});
|
|
60
|
+
try {
|
|
61
|
+
const ok = sdkCheck!.fn(ctx);
|
|
62
|
+
return { ok, output: lines.join("\n") };
|
|
63
|
+
} finally {
|
|
64
|
+
spy.mockRestore();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("verify check: 'No relative imports to deleted SDK files'", () => {
|
|
69
|
+
let tmp: string;
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "verify-sdk-"));
|
|
73
|
+
fs.mkdirSync(path.join(tmp, "src", "components"), { recursive: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("passes when src/ imports useOffer/useVariantPossiblities relatively (kept files, #212)", () => {
|
|
81
|
+
fs.writeFileSync(
|
|
82
|
+
path.join(tmp, "src", "components", "ProductCard.tsx"),
|
|
83
|
+
`import { useOffer } from "../../sdk/useOffer";\n` +
|
|
84
|
+
`import { useVariantPossibilities } from "../../sdk/useVariantPossiblities";\n` +
|
|
85
|
+
`export const x = 1;\n`,
|
|
86
|
+
);
|
|
87
|
+
const { ok, output } = runCheck(makeCtx(tmp));
|
|
88
|
+
expect(ok).toBe(true);
|
|
89
|
+
expect(output).toBe("");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("fails when src/ imports clx/useId/usePlatform relatively (truly deleted)", () => {
|
|
93
|
+
fs.writeFileSync(
|
|
94
|
+
path.join(tmp, "src", "components", "Bad.tsx"),
|
|
95
|
+
`import { clx } from "../../sdk/clx";\n` +
|
|
96
|
+
`import { useId } from "../../sdk/useId";\n` +
|
|
97
|
+
`import { usePlatform } from "../../sdk/usePlatform";\n` +
|
|
98
|
+
`export const x = 1;\n`,
|
|
99
|
+
);
|
|
100
|
+
const { ok, output } = runCheck(makeCtx(tmp));
|
|
101
|
+
expect(ok).toBe(false);
|
|
102
|
+
// The improved error reports each offending line, not just the file.
|
|
103
|
+
expect(output).toMatch(/components\/Bad\.tsx:.*sdk\/clx/);
|
|
104
|
+
expect(output).toMatch(/components\/Bad\.tsx:.*sdk\/useId/);
|
|
105
|
+
expect(output).toMatch(/components\/Bad\.tsx:.*sdk\/usePlatform/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("ignores commented-out references to deleted SDK files", () => {
|
|
109
|
+
fs.writeFileSync(
|
|
110
|
+
path.join(tmp, "src", "components", "Docs.tsx"),
|
|
111
|
+
`// import { clx } from "../../sdk/clx"\n` +
|
|
112
|
+
`// from "../../sdk/useId"\n` +
|
|
113
|
+
`export const x = 1;\n`,
|
|
114
|
+
);
|
|
115
|
+
const { ok } = runCheck(makeCtx(tmp));
|
|
116
|
+
expect(ok).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("matches both .ts-suffixed and unsuffixed import paths", () => {
|
|
120
|
+
fs.writeFileSync(
|
|
121
|
+
path.join(tmp, "src", "components", "WithExt.tsx"),
|
|
122
|
+
`import { clx } from "../../sdk/clx.ts";\nexport const x = 1;\n`,
|
|
123
|
+
);
|
|
124
|
+
const { ok } = runCheck(makeCtx(tmp));
|
|
125
|
+
expect(ok).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -59,7 +59,7 @@ const MUST_NOT_EXIST = [
|
|
|
59
59
|
"routes/_middleware.ts",
|
|
60
60
|
];
|
|
61
61
|
|
|
62
|
-
const checks: Check[] = [
|
|
62
|
+
export const checks: Check[] = [
|
|
63
63
|
{
|
|
64
64
|
name: "All scaffolded files exist",
|
|
65
65
|
severity: "error",
|
|
@@ -233,15 +233,24 @@ const checks: Check[] = [
|
|
|
233
233
|
},
|
|
234
234
|
},
|
|
235
235
|
{
|
|
236
|
+
// Only flag imports to files that phase-cleanup actually deletes:
|
|
237
|
+
// `sdk/clx.ts`, `sdk/useId.ts`, `sdk/usePlatform.tsx`. Earlier versions
|
|
238
|
+
// also matched `useOffer` and `useVariantPossiblities`, which are
|
|
239
|
+
// explicitly KEPT as site files (RELATIVE_SDK_REWRITES in
|
|
240
|
+
// transforms/imports.ts skips them on purpose because sites customize
|
|
241
|
+
// them). That false positive made every Magento/custom-SDK migration
|
|
242
|
+
// fail verify even when the imports were valid. See #212.
|
|
236
243
|
name: "No relative imports to deleted SDK files",
|
|
237
244
|
severity: "error",
|
|
238
245
|
fn: (ctx) => {
|
|
239
246
|
const srcDir = path.join(ctx.sourceDir, "src");
|
|
240
247
|
if (!fs.existsSync(srcDir)) return true;
|
|
241
|
-
|
|
242
|
-
const bad =
|
|
248
|
+
const pattern = /from\s+["'](?:\.\.?\/)[^"']*\/sdk\/(?:clx|useId|usePlatform)(?:\.tsx?)?["']/;
|
|
249
|
+
const bad = findMatchesWithPattern(srcDir, pattern);
|
|
243
250
|
if (bad.length > 0) {
|
|
244
|
-
|
|
251
|
+
for (const { file, line } of bad) {
|
|
252
|
+
console.log(` ${file}: ${line.trim()}`);
|
|
253
|
+
}
|
|
245
254
|
return false;
|
|
246
255
|
}
|
|
247
256
|
return true;
|
|
@@ -493,6 +502,37 @@ function findFilesWithPattern(
|
|
|
493
502
|
return results;
|
|
494
503
|
}
|
|
495
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Like {@link findFilesWithPattern} but returns each offending line alongside
|
|
507
|
+
* its file, so the verify output can show *what* matched — not just *where*.
|
|
508
|
+
* Skips comment-only lines so a doc reference doesn't trigger a false fail.
|
|
509
|
+
*/
|
|
510
|
+
function findMatchesWithPattern(
|
|
511
|
+
dir: string,
|
|
512
|
+
pattern: RegExp,
|
|
513
|
+
results: Array<{ file: string; line: string }> = [],
|
|
514
|
+
baseDir?: string,
|
|
515
|
+
): Array<{ file: string; line: string }> {
|
|
516
|
+
const root = baseDir ?? dir;
|
|
517
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
518
|
+
for (const entry of entries) {
|
|
519
|
+
const fullPath = path.join(dir, entry.name);
|
|
520
|
+
if (entry.isDirectory()) {
|
|
521
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server") continue;
|
|
522
|
+
findMatchesWithPattern(fullPath, pattern, results, root);
|
|
523
|
+
} else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
|
524
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
525
|
+
const rel = path.relative(root, fullPath);
|
|
526
|
+
for (const line of content.split("\n")) {
|
|
527
|
+
const trimmed = line.trimStart();
|
|
528
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
|
|
529
|
+
if (pattern.test(line)) results.push({ file: rel, line });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return results;
|
|
534
|
+
}
|
|
535
|
+
|
|
496
536
|
export function verify(ctx: MigrationContext): boolean {
|
|
497
537
|
logPhase("Verify (Smoke Test)");
|
|
498
538
|
|
package/scripts/migrate/types.ts
CHANGED
package/src/cms/loader.test.ts
CHANGED
|
@@ -139,6 +139,26 @@ describe("findPageByPath specificity", () => {
|
|
|
139
139
|
expect(match?.blockKey).toBe("pages-bf");
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
it("prefers the home page over an optional-group splat catch-all", () => {
|
|
143
|
+
// Regression: /{granado/}?* matches "/" and was out-ranking the home
|
|
144
|
+
// because the `{granado` segment counted as a param. The home block
|
|
145
|
+
// is a literal-only `/` path and must always win.
|
|
146
|
+
setBlocks({
|
|
147
|
+
"pages-home": {
|
|
148
|
+
name: "Home",
|
|
149
|
+
path: "/",
|
|
150
|
+
sections: [],
|
|
151
|
+
},
|
|
152
|
+
"pages-pdp-plp": {
|
|
153
|
+
name: "PDP & PLP",
|
|
154
|
+
path: "/{granado/}?*",
|
|
155
|
+
sections: [],
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
const match = findPageByPath("/");
|
|
159
|
+
expect(match?.blockKey).toBe("pages-home");
|
|
160
|
+
});
|
|
161
|
+
|
|
142
162
|
it("falls back to the splat page for unknown URLs", () => {
|
|
143
163
|
const match = findPageByPath("/perfumaria");
|
|
144
164
|
expect(match?.blockKey).toBe("pages-pdp-plp");
|
package/src/cms/loader.ts
CHANGED
|
@@ -128,28 +128,39 @@ export function withBlocksOverride<T>(override: Record<string, unknown>, fn: ()
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
// Higher key wins. Compared lexicographically:
|
|
131
|
-
// [literalSegments, paramSegments
|
|
132
|
-
// So `/foo/bar` > `/foo/:x` > `/foo/*` > `/*`, and `/my-account/*` > `/*`.
|
|
131
|
+
// [hasNoWildcard, literalSegments, paramSegments]
|
|
133
132
|
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
133
|
+
// `hasNoWildcard` is the top key so a literal-only path always beats any
|
|
134
|
+
// pattern that contains `*` or `{group}?` — including the empty-parts case
|
|
135
|
+
// `/` (literals=0) vs the catch-all `/{prefix/}?*` (literals=0, params=1).
|
|
136
|
+
// Without this, the URLPattern fix (#213/#214) inadvertently lets a
|
|
137
|
+
// `/{group/}?*` catch-all out-rank an exact `/` home page because the
|
|
138
|
+
// `{group` segment counted as a param. See deco-sites/granadobr-tanstack
|
|
139
|
+
// where `/` was being routed to the granado PDP/PLP block's NotFound
|
|
140
|
+
// fallback.
|
|
141
|
+
//
|
|
142
|
+
// Order produced:
|
|
143
|
+
// /foo/bar (no wildcard, literals=2) > /foo/:x (no wildcard, lit=1, param=1)
|
|
144
|
+
// /foo (no wildcard) > /{granado/}?* (has wildcard) > /*
|
|
138
145
|
function pathSpecificityKey(path: string): [number, number, number] {
|
|
139
146
|
const parts = path.split("/").filter(Boolean);
|
|
140
147
|
let literals = 0;
|
|
141
148
|
let params = 0;
|
|
142
|
-
let
|
|
149
|
+
let hasWildcard = false;
|
|
143
150
|
for (const part of parts) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
else
|
|
151
|
+
// A wildcard is any `*`, optional group `{...}?`, or any segment
|
|
152
|
+
// bearing `?` — these all make the pattern match strictly more URLs
|
|
153
|
+
// than a plain literal/`:param`/`:slug([\w-]+)` segment, so they
|
|
154
|
+
// are demoted to "least specific" together regardless of count.
|
|
155
|
+
if (part.includes("*") || /[{}?]/.test(part)) {
|
|
156
|
+
hasWildcard = true;
|
|
157
|
+
} else if (part.startsWith(":") || part.startsWith("$")) {
|
|
158
|
+
params++;
|
|
159
|
+
} else {
|
|
160
|
+
literals++;
|
|
161
|
+
}
|
|
151
162
|
}
|
|
152
|
-
return [
|
|
163
|
+
return [hasWildcard ? 0 : 1, literals, params];
|
|
153
164
|
}
|
|
154
165
|
|
|
155
166
|
export function getAllPages(): Array<{ key: string; page: DecoPage }> {
|