@decocms/start 2.8.0 → 2.10.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/.agents/skills/deco-migrate-script/SKILL.md +27 -0
- package/package.json +1 -1
- package/scripts/migrate/config.test.ts +202 -0
- package/scripts/migrate/config.ts +186 -0
- package/scripts/migrate/phase-transform.ts +21 -2
- package/scripts/migrate/templates/hooks.test.ts +85 -0
- package/scripts/migrate/templates/hooks.ts +8 -245
- package/scripts/migrate/transforms/section-conventions.ts +175 -150
- package/scripts/migrate/types.ts +14 -1
- package/scripts/migrate.ts +11 -0
|
@@ -34,6 +34,33 @@ npx tsx node_modules/@decocms/start/scripts/migrate.ts --source /path/to/old-sit
|
|
|
34
34
|
|
|
35
35
|
**CI usage:** pair `--strict` with `--with-build` to catch both type and runtime regressions before merge.
|
|
36
36
|
|
|
37
|
+
### Per-site config: `.deco-migrate.config.json`
|
|
38
|
+
|
|
39
|
+
Optional JSON file at the source root that customises the migration for sites whose section names don't match the casaevideo-derived defaults baked into the script.
|
|
40
|
+
|
|
41
|
+
```jsonc
|
|
42
|
+
{
|
|
43
|
+
"sectionConventions": {
|
|
44
|
+
// Add to defaults — preferred for sites that share most defaults.
|
|
45
|
+
"extend": {
|
|
46
|
+
"sync": ["MyCustomShelf"],
|
|
47
|
+
"listingCache": ["MyCustomShelf"],
|
|
48
|
+
"staticCache": ["AboutUs", "PrivacyPolicy"]
|
|
49
|
+
}
|
|
50
|
+
// Or replace defaults entirely (rare):
|
|
51
|
+
// "replace": { "sync": [...], "eagerSync": [...], ... }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Categories:**
|
|
57
|
+
- `eagerSync` — section files registered as both eager and sync (rendered above-the-fold, no client-defer).
|
|
58
|
+
- `sync` — registered as sync only (server-side default applies for loading).
|
|
59
|
+
- `listingCache` — emit `export const cache = "listing"` (medium TTL).
|
|
60
|
+
- `staticCache` — emit `export const cache = "static"` (long TTL).
|
|
61
|
+
|
|
62
|
+
When the file is absent the baked-in casaevideo defaults apply, so existing migrations are unaffected.
|
|
63
|
+
|
|
37
64
|
## Architecture
|
|
38
65
|
|
|
39
66
|
```
|
package/package.json
CHANGED
|
@@ -0,0 +1,202 @@
|
|
|
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 {
|
|
6
|
+
DEFAULT_SECTION_CONVENTIONS,
|
|
7
|
+
loadConfig,
|
|
8
|
+
resolveSectionConventions,
|
|
9
|
+
validateConfig,
|
|
10
|
+
} from "./config";
|
|
11
|
+
|
|
12
|
+
describe("loadConfig", () => {
|
|
13
|
+
let tmpDir: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "config-test-"));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns null when the config file is missing", () => {
|
|
24
|
+
expect(loadConfig(tmpDir)).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("loads valid JSON", () => {
|
|
28
|
+
const content = JSON.stringify({
|
|
29
|
+
sectionConventions: { extend: { sync: ["MySection"] } },
|
|
30
|
+
});
|
|
31
|
+
fs.writeFileSync(
|
|
32
|
+
path.join(tmpDir, ".deco-migrate.config.json"),
|
|
33
|
+
content,
|
|
34
|
+
"utf-8",
|
|
35
|
+
);
|
|
36
|
+
const config = loadConfig(tmpDir);
|
|
37
|
+
expect(config).toEqual({
|
|
38
|
+
sectionConventions: { extend: { sync: ["MySection"] } },
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("throws with a helpful error on malformed JSON", () => {
|
|
43
|
+
fs.writeFileSync(
|
|
44
|
+
path.join(tmpDir, ".deco-migrate.config.json"),
|
|
45
|
+
"not valid json{",
|
|
46
|
+
"utf-8",
|
|
47
|
+
);
|
|
48
|
+
expect(() => loadConfig(tmpDir)).toThrow(
|
|
49
|
+
/Failed to parse.*Expected valid JSON/s,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("resolveSectionConventions", () => {
|
|
55
|
+
it("returns the defaults when config is null", () => {
|
|
56
|
+
const sets = resolveSectionConventions(null);
|
|
57
|
+
expect(sets.eagerSync.has("UtilLinks")).toBe(true);
|
|
58
|
+
expect(sets.sync.has("ProductShelf")).toBe(true);
|
|
59
|
+
expect(sets.listingCache.has("ProductShelf")).toBe(true);
|
|
60
|
+
expect(sets.staticCache.has("Faq")).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("preserves all default eagerSync entries", () => {
|
|
64
|
+
const sets = resolveSectionConventions(null);
|
|
65
|
+
for (const name of DEFAULT_SECTION_CONVENTIONS.eagerSync ?? []) {
|
|
66
|
+
expect(sets.eagerSync.has(name)).toBe(true);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("extend mode adds to defaults", () => {
|
|
71
|
+
const sets = resolveSectionConventions({
|
|
72
|
+
sectionConventions: {
|
|
73
|
+
extend: { sync: ["MySection"], staticCache: ["MyStatic"] },
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
// Default still present
|
|
77
|
+
expect(sets.sync.has("ProductShelf")).toBe(true);
|
|
78
|
+
// Extension added
|
|
79
|
+
expect(sets.sync.has("MySection")).toBe(true);
|
|
80
|
+
// Default static still present
|
|
81
|
+
expect(sets.staticCache.has("Faq")).toBe(true);
|
|
82
|
+
// Extension added
|
|
83
|
+
expect(sets.staticCache.has("MyStatic")).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("extend mode handles partial extensions (only some categories)", () => {
|
|
87
|
+
const sets = resolveSectionConventions({
|
|
88
|
+
sectionConventions: { extend: { eagerSync: ["FrontFacing"] } },
|
|
89
|
+
});
|
|
90
|
+
// All defaults still present in untouched categories
|
|
91
|
+
expect(sets.sync.has("ProductShelf")).toBe(true);
|
|
92
|
+
expect(sets.staticCache.has("Faq")).toBe(true);
|
|
93
|
+
// Extension added
|
|
94
|
+
expect(sets.eagerSync.has("FrontFacing")).toBe(true);
|
|
95
|
+
// Defaults still present in extended category
|
|
96
|
+
expect(sets.eagerSync.has("UtilLinks")).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("replace mode discards defaults", () => {
|
|
100
|
+
const sets = resolveSectionConventions({
|
|
101
|
+
sectionConventions: {
|
|
102
|
+
replace: { sync: ["OnlyThis"] },
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
// Default removed
|
|
106
|
+
expect(sets.sync.has("ProductShelf")).toBe(false);
|
|
107
|
+
// Replacement present
|
|
108
|
+
expect(sets.sync.has("OnlyThis")).toBe(true);
|
|
109
|
+
// Untouched categories empty
|
|
110
|
+
expect(sets.eagerSync.size).toBe(0);
|
|
111
|
+
expect(sets.listingCache.size).toBe(0);
|
|
112
|
+
expect(sets.staticCache.size).toBe(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("replace mode is full replacement, not partial overlay", () => {
|
|
116
|
+
const sets = resolveSectionConventions({
|
|
117
|
+
sectionConventions: {
|
|
118
|
+
replace: {
|
|
119
|
+
eagerSync: ["X"],
|
|
120
|
+
sync: ["Y"],
|
|
121
|
+
listingCache: ["Z"],
|
|
122
|
+
staticCache: ["W"],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
expect(Array.from(sets.eagerSync)).toEqual(["X"]);
|
|
127
|
+
expect(Array.from(sets.sync)).toEqual(["Y"]);
|
|
128
|
+
expect(Array.from(sets.listingCache)).toEqual(["Z"]);
|
|
129
|
+
expect(Array.from(sets.staticCache)).toEqual(["W"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns empty sets when given empty arrays in replace", () => {
|
|
133
|
+
const sets = resolveSectionConventions({
|
|
134
|
+
sectionConventions: { replace: {} },
|
|
135
|
+
});
|
|
136
|
+
expect(sets.eagerSync.size).toBe(0);
|
|
137
|
+
expect(sets.sync.size).toBe(0);
|
|
138
|
+
expect(sets.listingCache.size).toBe(0);
|
|
139
|
+
expect(sets.staticCache.size).toBe(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns defaults when sectionConventions is undefined", () => {
|
|
143
|
+
const sets = resolveSectionConventions({});
|
|
144
|
+
expect(sets.sync.has("ProductShelf")).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("validateConfig", () => {
|
|
149
|
+
it("accepts an empty config", () => {
|
|
150
|
+
expect(() => validateConfig({})).not.toThrow();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("accepts a config with extend", () => {
|
|
154
|
+
expect(() =>
|
|
155
|
+
validateConfig({
|
|
156
|
+
sectionConventions: { extend: { sync: ["Foo"] } },
|
|
157
|
+
}),
|
|
158
|
+
).not.toThrow();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("accepts a config with replace", () => {
|
|
162
|
+
expect(() =>
|
|
163
|
+
validateConfig({
|
|
164
|
+
sectionConventions: { replace: { sync: ["Foo"] } },
|
|
165
|
+
}),
|
|
166
|
+
).not.toThrow();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("rejects non-object root", () => {
|
|
170
|
+
expect(() => validateConfig("bad")).toThrow(
|
|
171
|
+
/must be a JSON object/,
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("rejects non-object sectionConventions", () => {
|
|
176
|
+
expect(() => validateConfig({ sectionConventions: "bad" })).toThrow(
|
|
177
|
+
/sectionConventions must be an object/,
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("rejects non-array values in convention lists", () => {
|
|
182
|
+
expect(() =>
|
|
183
|
+
validateConfig({
|
|
184
|
+
sectionConventions: { extend: { sync: "not-an-array" } },
|
|
185
|
+
}),
|
|
186
|
+
).toThrow(/must be string\[\]/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("rejects non-string entries in convention lists", () => {
|
|
190
|
+
expect(() =>
|
|
191
|
+
validateConfig({
|
|
192
|
+
sectionConventions: { extend: { sync: [1, 2, 3] } },
|
|
193
|
+
}),
|
|
194
|
+
).toThrow(/must be string\[\]/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("ignores unknown top-level fields gracefully", () => {
|
|
198
|
+
expect(() =>
|
|
199
|
+
validateConfig({ unknownField: 123, sectionConventions: {} }),
|
|
200
|
+
).not.toThrow();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-site migration configuration.
|
|
3
|
+
*
|
|
4
|
+
* Looks for `.deco-migrate.config.json` next to the source root. The file
|
|
5
|
+
* is optional — without it the script falls back to a baked-in default set
|
|
6
|
+
* of section-convention names that work for `casaevideo` and most other
|
|
7
|
+
* Deco/VTEX sites that derived from the same template.
|
|
8
|
+
*
|
|
9
|
+
* The defaults are kept here (not in `transforms/section-conventions.ts`)
|
|
10
|
+
* so that:
|
|
11
|
+
* 1. They live alongside the schema that consumes them.
|
|
12
|
+
* 2. They can be overridden per site without forking the transform.
|
|
13
|
+
* 3. Other phases (analyze, report) can read them too.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Names of section files that get framework hints applied during the
|
|
21
|
+
* `transformSectionConventions` step.
|
|
22
|
+
*
|
|
23
|
+
* - `eagerSync`: render server-side eagerly *and* register as sync (no
|
|
24
|
+
* client-side defer).
|
|
25
|
+
* - `sync`: register as sync (sectionLoaders.sync), but server-side
|
|
26
|
+
* loading remains the default.
|
|
27
|
+
* - `listingCache`: `export const cache = "listing"` (medium TTL).
|
|
28
|
+
* - `staticCache`: `export const cache = "static"` (long TTL).
|
|
29
|
+
*/
|
|
30
|
+
export interface SectionConventionConfig {
|
|
31
|
+
eagerSync?: string[];
|
|
32
|
+
sync?: string[];
|
|
33
|
+
listingCache?: string[];
|
|
34
|
+
staticCache?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MigrateConfig {
|
|
38
|
+
sectionConventions?: {
|
|
39
|
+
/**
|
|
40
|
+
* Replace the built-in defaults entirely. Use only when porting a
|
|
41
|
+
* site whose section names don't overlap the defaults at all.
|
|
42
|
+
*/
|
|
43
|
+
replace?: SectionConventionConfig;
|
|
44
|
+
/**
|
|
45
|
+
* Add to the built-in defaults. Recommended path for sites that
|
|
46
|
+
* share most defaults but have a few extra section names.
|
|
47
|
+
*/
|
|
48
|
+
extend?: SectionConventionConfig;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolved sets used by `transformSectionConventions`. Always provided —
|
|
54
|
+
* either from defaults, from config replace, or defaults+config.extend.
|
|
55
|
+
*/
|
|
56
|
+
export interface SectionConventionSets {
|
|
57
|
+
eagerSync: Set<string>;
|
|
58
|
+
sync: Set<string>;
|
|
59
|
+
listingCache: Set<string>;
|
|
60
|
+
staticCache: Set<string>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Built-in defaults. Originally extracted from `casaevideo` migration —
|
|
65
|
+
* these names are common across Deco/VTEX storefronts that share the
|
|
66
|
+
* lineage. Sites that don't have these sections are unaffected (the
|
|
67
|
+
* matcher just never fires).
|
|
68
|
+
*/
|
|
69
|
+
export const DEFAULT_SECTION_CONVENTIONS: SectionConventionConfig = {
|
|
70
|
+
eagerSync: [
|
|
71
|
+
"UtilLinks",
|
|
72
|
+
"DepartamentList",
|
|
73
|
+
"ImageGallery",
|
|
74
|
+
"BannersGrid",
|
|
75
|
+
"Carousel",
|
|
76
|
+
"Tipbar",
|
|
77
|
+
"Live",
|
|
78
|
+
],
|
|
79
|
+
sync: [
|
|
80
|
+
"ProductShelf",
|
|
81
|
+
"ProductShelfTabbed",
|
|
82
|
+
"ProductShelfGroup",
|
|
83
|
+
"ProductShelfTopSort",
|
|
84
|
+
"CouponList",
|
|
85
|
+
"NotFoundChallenge",
|
|
86
|
+
"MountedPDP",
|
|
87
|
+
"BackgroundWrapper",
|
|
88
|
+
"SearchResult",
|
|
89
|
+
"LpCartao",
|
|
90
|
+
],
|
|
91
|
+
listingCache: [
|
|
92
|
+
"ProductShelf",
|
|
93
|
+
"ProductShelfTabbed",
|
|
94
|
+
"ProductShelfGroup",
|
|
95
|
+
"ProductShelfTimedOffers",
|
|
96
|
+
],
|
|
97
|
+
staticCache: ["InstagramPosts", "Faq"],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Load `.deco-migrate.config.json` from the source dir, if present. */
|
|
101
|
+
export function loadConfig(sourceDir: string): MigrateConfig | null {
|
|
102
|
+
const configPath = path.join(sourceDir, ".deco-migrate.config.json");
|
|
103
|
+
if (!fs.existsSync(configPath)) return null;
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(fs.readFileSync(configPath, "utf-8")) as MigrateConfig;
|
|
106
|
+
} catch (e) {
|
|
107
|
+
const msg = (e as Error).message;
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Failed to parse ${configPath}: ${msg}. Expected valid JSON.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Resolve config + defaults into the four sets the transform consumes. */
|
|
115
|
+
export function resolveSectionConventions(
|
|
116
|
+
config: MigrateConfig | null,
|
|
117
|
+
): SectionConventionSets {
|
|
118
|
+
const sc = config?.sectionConventions;
|
|
119
|
+
|
|
120
|
+
// Replace mode: use only what the user provided. No defaults mixed in.
|
|
121
|
+
if (sc?.replace) {
|
|
122
|
+
return toSets(sc.replace);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Default + extend: start from defaults, union in extend lists.
|
|
126
|
+
const merged: SectionConventionConfig = {
|
|
127
|
+
eagerSync: [
|
|
128
|
+
...(DEFAULT_SECTION_CONVENTIONS.eagerSync ?? []),
|
|
129
|
+
...(sc?.extend?.eagerSync ?? []),
|
|
130
|
+
],
|
|
131
|
+
sync: [
|
|
132
|
+
...(DEFAULT_SECTION_CONVENTIONS.sync ?? []),
|
|
133
|
+
...(sc?.extend?.sync ?? []),
|
|
134
|
+
],
|
|
135
|
+
listingCache: [
|
|
136
|
+
...(DEFAULT_SECTION_CONVENTIONS.listingCache ?? []),
|
|
137
|
+
...(sc?.extend?.listingCache ?? []),
|
|
138
|
+
],
|
|
139
|
+
staticCache: [
|
|
140
|
+
...(DEFAULT_SECTION_CONVENTIONS.staticCache ?? []),
|
|
141
|
+
...(sc?.extend?.staticCache ?? []),
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
return toSets(merged);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function toSets(c: SectionConventionConfig): SectionConventionSets {
|
|
148
|
+
return {
|
|
149
|
+
eagerSync: new Set(c.eagerSync ?? []),
|
|
150
|
+
sync: new Set(c.sync ?? []),
|
|
151
|
+
listingCache: new Set(c.listingCache ?? []),
|
|
152
|
+
staticCache: new Set(c.staticCache ?? []),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Cheap structural validation — throws on obviously invalid shapes. */
|
|
157
|
+
export function validateConfig(config: unknown): asserts config is MigrateConfig {
|
|
158
|
+
if (config === null || typeof config !== "object") {
|
|
159
|
+
throw new Error(".deco-migrate.config.json must be a JSON object");
|
|
160
|
+
}
|
|
161
|
+
const c = config as Record<string, unknown>;
|
|
162
|
+
const sc = c.sectionConventions;
|
|
163
|
+
if (sc === undefined) return;
|
|
164
|
+
if (typeof sc !== "object" || sc === null) {
|
|
165
|
+
throw new Error("sectionConventions must be an object");
|
|
166
|
+
}
|
|
167
|
+
const scObj = sc as Record<string, unknown>;
|
|
168
|
+
for (const key of ["replace", "extend"] as const) {
|
|
169
|
+
const v = scObj[key];
|
|
170
|
+
if (v === undefined) continue;
|
|
171
|
+
if (typeof v !== "object" || v === null) {
|
|
172
|
+
throw new Error(`sectionConventions.${key} must be an object`);
|
|
173
|
+
}
|
|
174
|
+
const sub = v as Record<string, unknown>;
|
|
175
|
+
for (const f of ["eagerSync", "sync", "listingCache", "staticCache"]) {
|
|
176
|
+
const arr = sub[f];
|
|
177
|
+
if (arr === undefined) continue;
|
|
178
|
+
if (
|
|
179
|
+
!Array.isArray(arr) ||
|
|
180
|
+
!arr.every((s): s is string => typeof s === "string")
|
|
181
|
+
) {
|
|
182
|
+
throw new Error(`sectionConventions.${key}.${f} must be string[]`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { resolveSectionConventions } from "./config";
|
|
3
4
|
import type { MigrationContext, TransformResult, SectionMeta } from "./types";
|
|
4
5
|
import { log, logPhase } from "./types";
|
|
5
6
|
import { transformImports } from "./transforms/imports";
|
|
@@ -8,7 +9,7 @@ import { transformFreshApis } from "./transforms/fresh-apis";
|
|
|
8
9
|
import { transformDenoIsms } from "./transforms/deno-isms";
|
|
9
10
|
import { transformTailwind } from "./transforms/tailwind";
|
|
10
11
|
import { transformDeadCode } from "./transforms/dead-code";
|
|
11
|
-
import {
|
|
12
|
+
import { createSectionConventionsTransform } from "./transforms/section-conventions";
|
|
12
13
|
|
|
13
14
|
/** Map of section path → metadata, populated per-run */
|
|
14
15
|
let sectionMetaMap: Map<string, SectionMeta> | null = null;
|
|
@@ -23,6 +24,22 @@ function getSectionMeta(ctx: MigrationContext, relPath: string): SectionMeta | u
|
|
|
23
24
|
return sectionMetaMap.get(relPath);
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Cached per-run section-conventions closure. Built once from the
|
|
29
|
+
* resolved config sets (`ctx.config.sectionConventions`), so casaevideo
|
|
30
|
+
* defaults still apply when no config file exists.
|
|
31
|
+
*/
|
|
32
|
+
let cachedSectionTransform:
|
|
33
|
+
| ReturnType<typeof createSectionConventionsTransform>
|
|
34
|
+
| null = null;
|
|
35
|
+
|
|
36
|
+
function getSectionConventionsTransform(ctx: MigrationContext) {
|
|
37
|
+
if (cachedSectionTransform) return cachedSectionTransform;
|
|
38
|
+
const sets = resolveSectionConventions(ctx.config ?? null);
|
|
39
|
+
cachedSectionTransform = createSectionConventionsTransform(sets);
|
|
40
|
+
return cachedSectionTransform;
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
/**
|
|
27
44
|
* Apply all transforms to a file's content in the correct order.
|
|
28
45
|
*/
|
|
@@ -59,7 +76,9 @@ function applyTransforms(content: string, filePath: string, ctx?: MigrationConte
|
|
|
59
76
|
// Section conventions (sync/eager/layout/cache) — only for section files
|
|
60
77
|
if (ctx && relPath && relPath.startsWith("sections/")) {
|
|
61
78
|
const meta = getSectionMeta(ctx, relPath);
|
|
62
|
-
|
|
79
|
+
// Build the closure once per ctx, cache it on the context.
|
|
80
|
+
const sectionTransform = getSectionConventionsTransform(ctx);
|
|
81
|
+
const result = sectionTransform(currentContent, meta);
|
|
63
82
|
if (result.changed) {
|
|
64
83
|
anyChanged = true;
|
|
65
84
|
currentContent = result.content;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { generateHooks } from "./hooks";
|
|
3
|
+
import type { MigrationContext } from "../types";
|
|
4
|
+
|
|
5
|
+
function makeCtx(platform: MigrationContext["platform"]): MigrationContext {
|
|
6
|
+
return {
|
|
7
|
+
sourceDir: "/tmp",
|
|
8
|
+
siteName: "test",
|
|
9
|
+
platform,
|
|
10
|
+
vtexAccount: null,
|
|
11
|
+
gtmId: null,
|
|
12
|
+
importMap: {},
|
|
13
|
+
discoveredNpmDeps: {},
|
|
14
|
+
themeColors: {},
|
|
15
|
+
fontFamily: null,
|
|
16
|
+
files: [],
|
|
17
|
+
sectionMetas: [],
|
|
18
|
+
islandClassifications: [],
|
|
19
|
+
islandWrapperTargets: new Map(),
|
|
20
|
+
loaderInventory: [],
|
|
21
|
+
scaffoldedFiles: [],
|
|
22
|
+
transformedFiles: [],
|
|
23
|
+
deletedFiles: [],
|
|
24
|
+
movedFiles: [],
|
|
25
|
+
manualReviewItems: [],
|
|
26
|
+
frameworkFindings: [],
|
|
27
|
+
dryRun: false,
|
|
28
|
+
verbose: false,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("generateHooks (vtex)", () => {
|
|
33
|
+
const files = generateHooks(makeCtx("vtex"));
|
|
34
|
+
|
|
35
|
+
it("emits all three hook files", () => {
|
|
36
|
+
expect(Object.keys(files).sort()).toEqual([
|
|
37
|
+
"src/hooks/useCart.ts",
|
|
38
|
+
"src/hooks/useUser.ts",
|
|
39
|
+
"src/hooks/useWishlist.ts",
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("useCart is the createUseCart factory shim, not 250-line legacy boilerplate", () => {
|
|
44
|
+
const code = files["src/hooks/useCart.ts"];
|
|
45
|
+
// Imports from the framework factory.
|
|
46
|
+
expect(code).toContain(
|
|
47
|
+
'import { createUseCart } from "@decocms/apps/vtex/hooks/createUseCart"',
|
|
48
|
+
);
|
|
49
|
+
expect(code).toContain('import { invoke } from "~/server/invoke"');
|
|
50
|
+
// Re-exports types from @decocms/apps directly.
|
|
51
|
+
expect(code).toContain(
|
|
52
|
+
'export type { OrderForm, OrderFormItem } from "@decocms/apps/vtex/types"',
|
|
53
|
+
);
|
|
54
|
+
// Calls the factory with invoke and destructures the public API.
|
|
55
|
+
expect(code).toContain(
|
|
56
|
+
"export const { useCart, resetCart, itemToAnalyticsItem } = createUseCart",
|
|
57
|
+
);
|
|
58
|
+
// And does NOT contain the old singleton machinery.
|
|
59
|
+
expect(code).not.toContain("const _listeners = new Set");
|
|
60
|
+
expect(code).not.toContain("forceRender");
|
|
61
|
+
expect(code).not.toContain("function getOrderFormIdFromCookie");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("useCart shim is dramatically smaller than the legacy template", () => {
|
|
65
|
+
const lineCount = files["src/hooks/useCart.ts"].split("\n").length;
|
|
66
|
+
// Should be well under 20 lines (factory call + re-export + imports).
|
|
67
|
+
expect(lineCount).toBeLessThan(20);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("generateHooks (non-vtex)", () => {
|
|
72
|
+
it("custom platform falls back to the generic stub", () => {
|
|
73
|
+
const files = generateHooks(makeCtx("custom"));
|
|
74
|
+
const code = files["src/hooks/useCart.ts"];
|
|
75
|
+
expect(code).toContain("Cart Hook stub");
|
|
76
|
+
expect(code).toContain("TODO: Implement");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("shopify currently shares the generic stub", () => {
|
|
80
|
+
const files = generateHooks(makeCtx("shopify"));
|
|
81
|
+
const code = files["src/hooks/useCart.ts"];
|
|
82
|
+
// Until a shopify factory exists, non-vtex platforms get the generic stub.
|
|
83
|
+
expect(code).toContain("Cart Hook stub");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -16,254 +16,17 @@ export function generateHooks(ctx: MigrationContext): Record<string, string> {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function generateVtexUseCart(): string {
|
|
19
|
-
|
|
19
|
+
// The legacy invoke-based useCart hook is now a 5-line factory call —
|
|
20
|
+
// the heavy lifting (singleton state, listener pattern, async actions,
|
|
21
|
+
// analytics helpers) lives in @decocms/apps/vtex/hooks/createUseCart.
|
|
22
|
+
return `import { createUseCart } from "@decocms/apps/vtex/hooks/createUseCart";
|
|
20
23
|
import { invoke } from "~/server/invoke";
|
|
21
|
-
import type { OrderForm, OrderFormItem } from "@decocms/apps/vtex/types";
|
|
22
24
|
|
|
23
|
-
export type { OrderForm, OrderFormItem };
|
|
25
|
+
export type { OrderForm, OrderFormItem } from "@decocms/apps/vtex/types";
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
let _initFailed = false;
|
|
29
|
-
const _listeners = new Set<() => void>();
|
|
30
|
-
|
|
31
|
-
function notify() {
|
|
32
|
-
_listeners.forEach((fn) => fn());
|
|
33
|
-
}
|
|
34
|
-
function setOrderForm(of: OrderForm | null) {
|
|
35
|
-
_orderForm = of;
|
|
36
|
-
notify();
|
|
37
|
-
}
|
|
38
|
-
function setLoading(v: boolean) {
|
|
39
|
-
_loading = v;
|
|
40
|
-
notify();
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function getOrderFormIdFromCookie(): string | null {
|
|
44
|
-
if (typeof document === "undefined") return null;
|
|
45
|
-
const match = document.cookie.match(/checkout\\.vtex\\.com__orderFormId=([^;]*)/);
|
|
46
|
-
return match ? decodeURIComponent(match[1]) : null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function setOrderFormIdCookie(id: string) {
|
|
50
|
-
if (typeof document === "undefined") return;
|
|
51
|
-
document.cookie = \`checkout.vtex.com__orderFormId=\${encodeURIComponent(id)}; path=/; max-age=\${7 * 24 * 3600}; SameSite=Lax\`;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async function ensureOrderForm(): Promise<string> {
|
|
55
|
-
if (_orderForm?.orderFormId) return _orderForm.orderFormId;
|
|
56
|
-
|
|
57
|
-
const existing = getOrderFormIdFromCookie();
|
|
58
|
-
const of = await invoke.vtex.actions.getOrCreateCart({
|
|
59
|
-
data: { orderFormId: existing || undefined },
|
|
60
|
-
});
|
|
61
|
-
setOrderForm(of);
|
|
62
|
-
if (of?.orderFormId) setOrderFormIdCookie(of.orderFormId);
|
|
63
|
-
return of.orderFormId;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function itemToAnalyticsItem(item: OrderFormItem & { coupon?: string }, index: number) {
|
|
67
|
-
return {
|
|
68
|
-
item_id: item.productId,
|
|
69
|
-
item_group_id: item.productId,
|
|
70
|
-
item_name: item.name ?? item.skuName ?? "",
|
|
71
|
-
item_variant: item.skuName,
|
|
72
|
-
item_brand: item.additionalInfo?.brandName ?? "",
|
|
73
|
-
price: (item.sellingPrice ?? item.price ?? 0) / 100,
|
|
74
|
-
discount: Number(((item.listPrice - item.sellingPrice) / 100).toFixed(2)),
|
|
75
|
-
quantity: item.quantity,
|
|
76
|
-
coupon: item.coupon,
|
|
77
|
-
affiliation: item.seller,
|
|
78
|
-
index,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function resetCart() {
|
|
83
|
-
_orderForm = null;
|
|
84
|
-
_loading = false;
|
|
85
|
-
_initStarted = false;
|
|
86
|
-
_initFailed = false;
|
|
87
|
-
notify();
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function useCart() {
|
|
91
|
-
const [, forceRender] = useState(0);
|
|
92
|
-
|
|
93
|
-
useEffect(() => {
|
|
94
|
-
const listener = () => forceRender((n) => n + 1);
|
|
95
|
-
_listeners.add(listener);
|
|
96
|
-
|
|
97
|
-
if (!_orderForm && !_initStarted) {
|
|
98
|
-
_initStarted = true;
|
|
99
|
-
const ofId = getOrderFormIdFromCookie();
|
|
100
|
-
setLoading(true);
|
|
101
|
-
invoke.vtex.actions
|
|
102
|
-
.getOrCreateCart({ data: { orderFormId: ofId || undefined } })
|
|
103
|
-
.then((of: OrderForm) => {
|
|
104
|
-
setOrderForm(of);
|
|
105
|
-
if (of?.orderFormId) setOrderFormIdCookie(of.orderFormId);
|
|
106
|
-
})
|
|
107
|
-
.catch((err: unknown) => {
|
|
108
|
-
console.error("[useCart] init failed:", err);
|
|
109
|
-
if (!_orderForm) {
|
|
110
|
-
_initFailed = true;
|
|
111
|
-
notify();
|
|
112
|
-
}
|
|
113
|
-
})
|
|
114
|
-
.finally(() => setLoading(false));
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return () => {
|
|
118
|
-
_listeners.delete(listener);
|
|
119
|
-
};
|
|
120
|
-
}, []);
|
|
121
|
-
|
|
122
|
-
return {
|
|
123
|
-
cart: {
|
|
124
|
-
get value() { return _orderForm; },
|
|
125
|
-
set value(v: OrderForm | null) { setOrderForm(v); },
|
|
126
|
-
},
|
|
127
|
-
|
|
128
|
-
loading: {
|
|
129
|
-
get value() { return _loading; },
|
|
130
|
-
set value(v: boolean) { setLoading(v); },
|
|
131
|
-
},
|
|
132
|
-
|
|
133
|
-
initFailed: {
|
|
134
|
-
get value() { return _initFailed; },
|
|
135
|
-
},
|
|
136
|
-
|
|
137
|
-
addItem: async (params: { id: string; seller: string; quantity?: number }) => {
|
|
138
|
-
setLoading(true);
|
|
139
|
-
try {
|
|
140
|
-
const ofId = await ensureOrderForm();
|
|
141
|
-
const updated = await invoke.vtex.actions.addItemsToCart({
|
|
142
|
-
data: {
|
|
143
|
-
orderFormId: ofId,
|
|
144
|
-
orderItems: [{ id: params.id, seller: params.seller, quantity: params.quantity ?? 1 }],
|
|
145
|
-
},
|
|
146
|
-
});
|
|
147
|
-
setOrderForm(updated);
|
|
148
|
-
if (updated?.orderFormId) setOrderFormIdCookie(updated.orderFormId);
|
|
149
|
-
} catch (err) {
|
|
150
|
-
console.error("[useCart] addItem failed:", err);
|
|
151
|
-
throw err;
|
|
152
|
-
} finally {
|
|
153
|
-
setLoading(false);
|
|
154
|
-
}
|
|
155
|
-
},
|
|
156
|
-
|
|
157
|
-
addItems: async (params: {
|
|
158
|
-
orderItems: Array<{ id: string; seller: string; quantity: number }>;
|
|
159
|
-
}) => {
|
|
160
|
-
setLoading(true);
|
|
161
|
-
try {
|
|
162
|
-
const ofId = await ensureOrderForm();
|
|
163
|
-
const updated = await invoke.vtex.actions.addItemsToCart({
|
|
164
|
-
data: { orderFormId: ofId, orderItems: params.orderItems },
|
|
165
|
-
});
|
|
166
|
-
setOrderForm(updated);
|
|
167
|
-
if (updated?.orderFormId) setOrderFormIdCookie(updated.orderFormId);
|
|
168
|
-
} catch (err) {
|
|
169
|
-
console.error("[useCart] addItems failed:", err);
|
|
170
|
-
throw err;
|
|
171
|
-
} finally {
|
|
172
|
-
setLoading(false);
|
|
173
|
-
}
|
|
174
|
-
},
|
|
175
|
-
|
|
176
|
-
updateItems: async (params: { orderItems: Array<{ index: number; quantity: number }> }) => {
|
|
177
|
-
const ofId = _orderForm?.orderFormId || getOrderFormIdFromCookie();
|
|
178
|
-
if (!ofId) return;
|
|
179
|
-
setLoading(true);
|
|
180
|
-
try {
|
|
181
|
-
const updated = await invoke.vtex.actions.updateCartItems({
|
|
182
|
-
data: { orderFormId: ofId, orderItems: params.orderItems },
|
|
183
|
-
});
|
|
184
|
-
setOrderForm(updated);
|
|
185
|
-
} catch (err) {
|
|
186
|
-
console.error("[useCart] updateItems failed:", err);
|
|
187
|
-
} finally {
|
|
188
|
-
setLoading(false);
|
|
189
|
-
}
|
|
190
|
-
},
|
|
191
|
-
|
|
192
|
-
removeItem: async (index: number) => {
|
|
193
|
-
const ofId = _orderForm?.orderFormId || getOrderFormIdFromCookie();
|
|
194
|
-
if (!ofId) return;
|
|
195
|
-
setLoading(true);
|
|
196
|
-
try {
|
|
197
|
-
const updated = await invoke.vtex.actions.updateCartItems({
|
|
198
|
-
data: { orderFormId: ofId, orderItems: [{ index, quantity: 0 }] },
|
|
199
|
-
});
|
|
200
|
-
setOrderForm(updated);
|
|
201
|
-
} catch (err) {
|
|
202
|
-
console.error("[useCart] removeItem failed:", err);
|
|
203
|
-
} finally {
|
|
204
|
-
setLoading(false);
|
|
205
|
-
}
|
|
206
|
-
},
|
|
207
|
-
|
|
208
|
-
addCouponsToCart: async ({ text }: { text: string }) => {
|
|
209
|
-
const ofId = _orderForm?.orderFormId || getOrderFormIdFromCookie();
|
|
210
|
-
if (!ofId) return;
|
|
211
|
-
setLoading(true);
|
|
212
|
-
try {
|
|
213
|
-
const updated = await invoke.vtex.actions.addCouponToCart({
|
|
214
|
-
data: { orderFormId: ofId, text },
|
|
215
|
-
});
|
|
216
|
-
setOrderForm(updated);
|
|
217
|
-
} catch (err) {
|
|
218
|
-
console.error("[useCart] addCoupon failed:", err);
|
|
219
|
-
} finally {
|
|
220
|
-
setLoading(false);
|
|
221
|
-
}
|
|
222
|
-
},
|
|
223
|
-
|
|
224
|
-
sendAttachment: async (params: { attachment: string; body: Record<string, unknown> }) => {
|
|
225
|
-
const ofId = _orderForm?.orderFormId || getOrderFormIdFromCookie();
|
|
226
|
-
if (!ofId) return;
|
|
227
|
-
setLoading(true);
|
|
228
|
-
try {
|
|
229
|
-
const updated = await invoke.vtex.actions.updateOrderFormAttachment({
|
|
230
|
-
data: {
|
|
231
|
-
orderFormId: ofId,
|
|
232
|
-
attachment: params.attachment,
|
|
233
|
-
body: params.body,
|
|
234
|
-
},
|
|
235
|
-
});
|
|
236
|
-
setOrderForm(updated);
|
|
237
|
-
} catch (err) {
|
|
238
|
-
console.error("[useCart] sendAttachment failed:", err);
|
|
239
|
-
} finally {
|
|
240
|
-
setLoading(false);
|
|
241
|
-
}
|
|
242
|
-
},
|
|
243
|
-
|
|
244
|
-
simulate: async (data: {
|
|
245
|
-
items: Array<{ id: string; quantity: number; seller: string }>;
|
|
246
|
-
postalCode: string;
|
|
247
|
-
country: string;
|
|
248
|
-
}) => {
|
|
249
|
-
return await invoke.vtex.actions.simulateCart({
|
|
250
|
-
data: {
|
|
251
|
-
items: data.items.map((i) => ({
|
|
252
|
-
id: i.id,
|
|
253
|
-
quantity: i.quantity,
|
|
254
|
-
seller: i.seller,
|
|
255
|
-
})),
|
|
256
|
-
postalCode: data.postalCode,
|
|
257
|
-
country: data.country,
|
|
258
|
-
},
|
|
259
|
-
});
|
|
260
|
-
},
|
|
261
|
-
|
|
262
|
-
mapItemsToAnalyticsItems: (orderForm: OrderForm | null) => {
|
|
263
|
-
return (orderForm?.items || []).map((item, index) => itemToAnalyticsItem(item, index));
|
|
264
|
-
},
|
|
265
|
-
};
|
|
266
|
-
}
|
|
27
|
+
export const { useCart, resetCart, itemToAnalyticsItem } = createUseCart({
|
|
28
|
+
invoke,
|
|
29
|
+
});
|
|
267
30
|
`;
|
|
268
31
|
}
|
|
269
32
|
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
SectionConventionSets,
|
|
3
|
+
} from "../config";
|
|
4
|
+
import { resolveSectionConventions } from "../config";
|
|
5
|
+
import type { SectionMeta, TransformResult } from "../types";
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* Adds section convention exports (sync, eager, layout, cache)
|
|
@@ -6,158 +10,179 @@ import type { TransformResult, SectionMeta } from "../types";
|
|
|
6
10
|
*
|
|
7
11
|
* These exports are read by generate-sections.ts in @decocms/start
|
|
8
12
|
* to build the sections.gen.ts registry.
|
|
13
|
+
*
|
|
14
|
+
* The set of section *names* that get hints applied is configurable
|
|
15
|
+
* via `.deco-migrate.config.json` (see `migrate/config.ts`). The exported
|
|
16
|
+
* `transformSectionConventions` keeps a back-compat signature using the
|
|
17
|
+
* baked-in defaults; new callers should prefer
|
|
18
|
+
* `createSectionConventionsTransform(sets)` so config can drive the lists.
|
|
9
19
|
*/
|
|
10
20
|
|
|
11
|
-
const EAGER_SYNC_SECTIONS = new Set([
|
|
12
|
-
"UtilLinks",
|
|
13
|
-
"DepartamentList",
|
|
14
|
-
"ImageGallery",
|
|
15
|
-
"BannersGrid",
|
|
16
|
-
"Carousel",
|
|
17
|
-
"Tipbar",
|
|
18
|
-
"Live",
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
|
-
const SYNC_SECTIONS = new Set([
|
|
22
|
-
"ProductShelf",
|
|
23
|
-
"ProductShelfTabbed",
|
|
24
|
-
"ProductShelfGroup",
|
|
25
|
-
"ProductShelfTopSort",
|
|
26
|
-
"CouponList",
|
|
27
|
-
"NotFoundChallenge",
|
|
28
|
-
"MountedPDP",
|
|
29
|
-
"BackgroundWrapper",
|
|
30
|
-
"SearchResult",
|
|
31
|
-
"LpCartao",
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
|
-
const LISTING_CACHE_SECTIONS = new Set([
|
|
35
|
-
"ProductShelf",
|
|
36
|
-
"ProductShelfTabbed",
|
|
37
|
-
"ProductShelfGroup",
|
|
38
|
-
"ProductShelfTimedOffers",
|
|
39
|
-
]);
|
|
40
|
-
|
|
41
|
-
const STATIC_CACHE_SECTIONS = new Set([
|
|
42
|
-
"InstagramPosts",
|
|
43
|
-
"Faq",
|
|
44
|
-
]);
|
|
45
|
-
|
|
46
21
|
function getSectionBasename(filePath: string): string {
|
|
47
|
-
|
|
22
|
+
return filePath.split("/").pop()?.replace(/\.\w+$/, "") || "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build a `transformSectionConventions` closure bound to the given
|
|
27
|
+
* resolved sets. This is the preferred entry point — the caller (usually
|
|
28
|
+
* `phase-transform`) loads config once and passes the sets in.
|
|
29
|
+
*/
|
|
30
|
+
export function createSectionConventionsTransform(
|
|
31
|
+
sets: SectionConventionSets,
|
|
32
|
+
) {
|
|
33
|
+
return (
|
|
34
|
+
content: string,
|
|
35
|
+
sectionMeta: SectionMeta | undefined,
|
|
36
|
+
): TransformResult => transformWithSets(content, sectionMeta, sets);
|
|
48
37
|
}
|
|
49
38
|
|
|
39
|
+
/** Default-configured transform. Uses the baked-in defaults from `config.ts`. */
|
|
40
|
+
const defaultSets = resolveSectionConventions(null);
|
|
41
|
+
|
|
50
42
|
export function transformSectionConventions(
|
|
51
|
-
|
|
52
|
-
|
|
43
|
+
content: string,
|
|
44
|
+
sectionMeta: SectionMeta | undefined,
|
|
45
|
+
): TransformResult {
|
|
46
|
+
return transformWithSets(content, sectionMeta, defaultSets);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function transformWithSets(
|
|
50
|
+
content: string,
|
|
51
|
+
sectionMeta: SectionMeta | undefined,
|
|
52
|
+
sets: SectionConventionSets,
|
|
53
53
|
): TransformResult {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
54
|
+
if (!sectionMeta) {
|
|
55
|
+
return { content, changed: false, notes: [] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const notes: string[] = [];
|
|
59
|
+
let result = content;
|
|
60
|
+
let changed = false;
|
|
61
|
+
const basename = getSectionBasename(sectionMeta.path);
|
|
62
|
+
|
|
63
|
+
// Header, footer, theme → eager + sync + layout
|
|
64
|
+
if (sectionMeta.isHeader || sectionMeta.isFooter || sectionMeta.isTheme) {
|
|
65
|
+
if (!result.includes("export const eager")) {
|
|
66
|
+
result += "\nexport const eager = true;\n";
|
|
67
|
+
notes.push("Added: export const eager = true");
|
|
68
|
+
changed = true;
|
|
69
|
+
}
|
|
70
|
+
if (!result.includes("export const sync")) {
|
|
71
|
+
result += "export const sync = true;\n";
|
|
72
|
+
notes.push("Added: export const sync = true");
|
|
73
|
+
changed = true;
|
|
74
|
+
}
|
|
75
|
+
// Header in golden does NOT have layout=true; only footer+theme do
|
|
76
|
+
if (
|
|
77
|
+
(sectionMeta.isFooter || sectionMeta.isTheme) &&
|
|
78
|
+
!result.includes("export const layout")
|
|
79
|
+
) {
|
|
80
|
+
result += "export const layout = true;\n";
|
|
81
|
+
notes.push("Added: export const layout = true");
|
|
82
|
+
changed = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Known eager+sync sections (non-layout)
|
|
87
|
+
if (sets.eagerSync.has(basename)) {
|
|
88
|
+
if (!result.includes("export const eager")) {
|
|
89
|
+
result += "\nexport const eager = true;\n";
|
|
90
|
+
notes.push(`Added: export const eager = true (${basename})`);
|
|
91
|
+
changed = true;
|
|
92
|
+
}
|
|
93
|
+
if (!result.includes("export const sync")) {
|
|
94
|
+
result += "export const sync = true;\n";
|
|
95
|
+
notes.push(`Added: export const sync = true (${basename})`);
|
|
96
|
+
changed = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Known sync-only sections
|
|
101
|
+
if (sets.sync.has(basename) && !result.includes("export const sync")) {
|
|
102
|
+
result += "\nexport const sync = true;\n";
|
|
103
|
+
notes.push(`Added: export const sync = true (${basename})`);
|
|
104
|
+
changed = true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Listing cache sections
|
|
108
|
+
if (
|
|
109
|
+
sets.listingCache.has(basename) &&
|
|
110
|
+
!result.includes("export const cache")
|
|
111
|
+
) {
|
|
112
|
+
result += '\nexport const cache = "listing";\n';
|
|
113
|
+
notes.push(`Added: export const cache = "listing" (${basename})`);
|
|
114
|
+
changed = true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Static cache sections
|
|
118
|
+
if (
|
|
119
|
+
sets.staticCache.has(basename) &&
|
|
120
|
+
!result.includes("export const cache")
|
|
121
|
+
) {
|
|
122
|
+
result += '\nexport const cache = "static";\n';
|
|
123
|
+
notes.push(`Added: export const cache = "static" (${basename})`);
|
|
124
|
+
changed = true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Generic: listing sections not already matched above
|
|
128
|
+
if (sectionMeta.isListing && !result.includes("export const cache")) {
|
|
129
|
+
result += '\nexport const cache = "listing";\n';
|
|
130
|
+
notes.push('Added: export const cache = "listing"');
|
|
131
|
+
changed = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Sections with loaders that use device → add sync (needs SSR device detection)
|
|
135
|
+
if (
|
|
136
|
+
sectionMeta.hasLoader &&
|
|
137
|
+
sectionMeta.loaderUsesDevice &&
|
|
138
|
+
!result.includes("export const sync")
|
|
139
|
+
) {
|
|
140
|
+
result += "\nexport const sync = true;\n";
|
|
141
|
+
notes.push("Added: export const sync = true (loader uses device)");
|
|
142
|
+
changed = true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Sections that render nested Section children need sync so they're in
|
|
146
|
+
// the syncComponents registry (SectionRenderer resolves the string key).
|
|
147
|
+
const hasNestedSections =
|
|
148
|
+
/children:\s*Section\b/.test(result) ||
|
|
149
|
+
/fallback:\s*Section\b/.test(result);
|
|
150
|
+
if (hasNestedSections && !result.includes("export const sync")) {
|
|
151
|
+
result += "\nexport const sync = true;\n";
|
|
152
|
+
notes.push(
|
|
153
|
+
"Added: export const sync = true (renders nested Section children)",
|
|
154
|
+
);
|
|
155
|
+
changed = true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Re-export sections that wrap PDP/nested content need sync too.
|
|
159
|
+
// Detect: file is a re-export AND the target component renders nested Sections
|
|
160
|
+
const isReExport = /^export\s+\{[^}]*default[^}]*\}\s+from\s+/.test(
|
|
161
|
+
result.trim(),
|
|
162
|
+
);
|
|
163
|
+
if (
|
|
164
|
+
isReExport &&
|
|
165
|
+
(basename === "MountedPDP" || basename === "NotFoundChallenge")
|
|
166
|
+
) {
|
|
167
|
+
if (!result.includes("export const sync")) {
|
|
168
|
+
result += "\nexport const sync = true;\n";
|
|
169
|
+
notes.push(`Added: export const sync = true (re-export: ${basename})`);
|
|
170
|
+
changed = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Don't add LoadingFallback re-exports to thin section files —
|
|
175
|
+
// we can't guarantee the target component exports it.
|
|
176
|
+
// Instead, if it's a listing section, a generic skeleton will be added below.
|
|
177
|
+
|
|
178
|
+
// Generate a basic LoadingFallback if the section doesn't have one
|
|
179
|
+
// and it's a listing section (visible skeleton improvement)
|
|
180
|
+
if (
|
|
181
|
+
sectionMeta.isListing &&
|
|
182
|
+
!sectionMeta.hasLoadingFallback &&
|
|
183
|
+
!result.includes("LoadingFallback")
|
|
184
|
+
) {
|
|
185
|
+
result += `
|
|
161
186
|
export function LoadingFallback() {
|
|
162
187
|
return (
|
|
163
188
|
<div className="w-full py-8">
|
|
@@ -177,9 +202,9 @@ export function LoadingFallback() {
|
|
|
177
202
|
);
|
|
178
203
|
}
|
|
179
204
|
`;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
205
|
+
notes.push("Added: LoadingFallback skeleton for listing section");
|
|
206
|
+
changed = true;
|
|
207
|
+
}
|
|
183
208
|
|
|
184
|
-
|
|
209
|
+
return { content: result, changed, notes };
|
|
185
210
|
}
|
package/scripts/migrate/types.ts
CHANGED
|
@@ -137,6 +137,14 @@ export interface MigrationContext {
|
|
|
137
137
|
vtexAccount: string | null;
|
|
138
138
|
gtmId: string | null;
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Per-site config loaded from `.deco-migrate.config.json`. `null` means
|
|
142
|
+
* no config file was present — defaults apply throughout. Imported
|
|
143
|
+
* lazily as `MigrateConfig` to avoid a circular dependency between
|
|
144
|
+
* `types.ts` and `config.ts`.
|
|
145
|
+
*/
|
|
146
|
+
config?: import("./config").MigrateConfig | null;
|
|
147
|
+
|
|
140
148
|
/** deno.json import map entries */
|
|
141
149
|
importMap: Record<string, string>;
|
|
142
150
|
|
|
@@ -191,7 +199,11 @@ export interface TransformResult {
|
|
|
191
199
|
|
|
192
200
|
export function createContext(
|
|
193
201
|
sourceDir: string,
|
|
194
|
-
opts: {
|
|
202
|
+
opts: {
|
|
203
|
+
dryRun?: boolean;
|
|
204
|
+
verbose?: boolean;
|
|
205
|
+
config?: import("./config").MigrateConfig | null;
|
|
206
|
+
} = {},
|
|
195
207
|
): MigrationContext {
|
|
196
208
|
return {
|
|
197
209
|
sourceDir,
|
|
@@ -199,6 +211,7 @@ export function createContext(
|
|
|
199
211
|
platform: "custom",
|
|
200
212
|
vtexAccount: null,
|
|
201
213
|
gtmId: null,
|
|
214
|
+
config: opts.config ?? null,
|
|
202
215
|
importMap: {},
|
|
203
216
|
discoveredNpmDeps: {},
|
|
204
217
|
themeColors: {},
|
package/scripts/migrate.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import * as path from "node:path";
|
|
27
27
|
import { execSync } from "node:child_process";
|
|
28
|
+
import { loadConfig, validateConfig } from "./migrate/config";
|
|
28
29
|
import { createContext, logPhase } from "./migrate/types";
|
|
29
30
|
import { analyze } from "./migrate/phase-analyze";
|
|
30
31
|
import { scaffold } from "./migrate/phase-scaffold";
|
|
@@ -121,9 +122,19 @@ async function main() {
|
|
|
121
122
|
stat("Mode", opts.dryRun ? yellow("DRY RUN") : green("EXECUTE"));
|
|
122
123
|
stat("Verbose", opts.verbose ? "yes" : "no");
|
|
123
124
|
|
|
125
|
+
// Load optional per-site config from `.deco-migrate.config.json`. Drives
|
|
126
|
+
// section-conventions hardcoded lists today; future fields will tune
|
|
127
|
+
// import rewrites, scaffolding, etc.
|
|
128
|
+
const siteConfig = loadConfig(sourceDir);
|
|
129
|
+
if (siteConfig) {
|
|
130
|
+
validateConfig(siteConfig);
|
|
131
|
+
stat("Config", green(".deco-migrate.config.json (loaded)"));
|
|
132
|
+
}
|
|
133
|
+
|
|
124
134
|
const ctx = createContext(sourceDir, {
|
|
125
135
|
dryRun: opts.dryRun,
|
|
126
136
|
verbose: opts.verbose,
|
|
137
|
+
config: siteConfig,
|
|
127
138
|
});
|
|
128
139
|
|
|
129
140
|
try {
|