@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "6.4.3",
3
+ "version": "6.4.5",
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",
@@ -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
- const platforms: Platform[] = ["vtex", "shopify", "wake", "vnda", "linx", "nuvemshop"];
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
- // Only match relative imports (../ or ./) to deleted SDK files, not @decocms/* package imports
242
- const bad = findFilesWithPattern(srcDir, /from\s+["'](?:\.\.?\/)[^"']*\/sdk\/(?:clx|useId|useOffer|useVariantPossiblities|usePlatform)["']/);
248
+ const pattern = /from\s+["'](?:\.\.?\/)[^"']*\/sdk\/(?:clx|useId|usePlatform)(?:\.tsx?)?["']/;
249
+ const bad = findMatchesWithPattern(srcDir, pattern);
243
250
  if (bad.length > 0) {
244
- console.log(` Still has relative imports to deleted SDK files: ${bad.join(", ")}`);
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
 
@@ -5,6 +5,7 @@ export type Platform =
5
5
  | "wake"
6
6
  | "linx"
7
7
  | "nuvemshop"
8
+ | "magento"
8
9
  | "custom";
9
10
 
10
11
  export interface FileRecord {
@@ -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, hasNoSplat]
132
- // So `/foo/bar` > `/foo/:x` > `/foo/*` > `/*`, and `/my-account/*` > `/*`.
131
+ // [hasNoWildcard, literalSegments, paramSegments]
133
132
  //
134
- // URLPattern syntax (`{group}?`, `:slug([\w-]+)`, trailing `*`) is supported:
135
- // any segment containing `{`, `}`, or `?` counts as a param, and a segment
136
- // containing `*` flips the splat bit. This keeps optional-group patterns
137
- // (e.g. `/{granado/}?*`) from out-ranking real literal pages.
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 hasSplat = false;
149
+ let hasWildcard = false;
143
150
  for (const part of parts) {
144
- if (part.includes("*")) hasSplat = true;
145
- else if (
146
- part.startsWith(":") ||
147
- part.startsWith("$") ||
148
- /[{}?]/.test(part)
149
- ) params++;
150
- else literals++;
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 [literals, params, hasSplat ? 0 : 1];
163
+ return [hasWildcard ? 0 : 1, literals, params];
153
164
  }
154
165
 
155
166
  export function getAllPages(): Array<{ key: string; page: DecoPage }> {