@decocms/start 2.9.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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
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,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 { transformSectionConventions } from "./transforms/section-conventions";
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
- const result = transformSectionConventions(currentContent, meta);
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;
@@ -1,4 +1,8 @@
1
- import type { TransformResult, SectionMeta } from "../types";
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
- return filePath.split("/").pop()?.replace(/\.\w+$/, "") || "";
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
- content: string,
52
- sectionMeta: SectionMeta | undefined,
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
- 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 ((sectionMeta.isFooter || sectionMeta.isTheme) && !result.includes("export const layout")) {
77
- result += "export const layout = true;\n";
78
- notes.push("Added: export const layout = true");
79
- changed = true;
80
- }
81
- }
82
-
83
- // Known eager+sync sections (non-layout)
84
- if (EAGER_SYNC_SECTIONS.has(basename)) {
85
- if (!result.includes("export const eager")) {
86
- result += "\nexport const eager = true;\n";
87
- notes.push(`Added: export const eager = true (${basename})`);
88
- changed = true;
89
- }
90
- if (!result.includes("export const sync")) {
91
- result += "export const sync = true;\n";
92
- notes.push(`Added: export const sync = true (${basename})`);
93
- changed = true;
94
- }
95
- }
96
-
97
- // Known sync-only sections
98
- if (SYNC_SECTIONS.has(basename) && !result.includes("export const sync")) {
99
- result += "\nexport const sync = true;\n";
100
- notes.push(`Added: export const sync = true (${basename})`);
101
- changed = true;
102
- }
103
-
104
- // Listing cache sections
105
- if (LISTING_CACHE_SECTIONS.has(basename) && !result.includes("export const cache")) {
106
- result += '\nexport const cache = "listing";\n';
107
- notes.push(`Added: export const cache = "listing" (${basename})`);
108
- changed = true;
109
- }
110
-
111
- // Static cache sections
112
- if (STATIC_CACHE_SECTIONS.has(basename) && !result.includes("export const cache")) {
113
- result += '\nexport const cache = "static";\n';
114
- notes.push(`Added: export const cache = "static" (${basename})`);
115
- changed = true;
116
- }
117
-
118
- // Generic: listing sections not already matched above
119
- if (sectionMeta.isListing && !result.includes("export const cache")) {
120
- result += '\nexport const cache = "listing";\n';
121
- notes.push('Added: export const cache = "listing"');
122
- changed = true;
123
- }
124
-
125
- // Sections with loaders that use device → add sync (needs SSR device detection)
126
- if (sectionMeta.hasLoader && sectionMeta.loaderUsesDevice && !result.includes("export const sync")) {
127
- result += "\nexport const sync = true;\n";
128
- notes.push("Added: export const sync = true (loader uses device)");
129
- changed = true;
130
- }
131
-
132
- // Sections that render nested Section children need sync so they're in
133
- // the syncComponents registry (SectionRenderer resolves the string key).
134
- const hasNestedSections =
135
- /children:\s*Section\b/.test(result) || /fallback:\s*Section\b/.test(result);
136
- if (hasNestedSections && !result.includes("export const sync")) {
137
- result += "\nexport const sync = true;\n";
138
- notes.push("Added: export const sync = true (renders nested Section children)");
139
- changed = true;
140
- }
141
-
142
- // Re-export sections that wrap PDP/nested content need sync too.
143
- // Detect: file is a re-export AND the target component renders nested Sections
144
- const isReExport = /^export\s+\{[^}]*default[^}]*\}\s+from\s+/.test(result.trim());
145
- if (isReExport && (basename === "MountedPDP" || basename === "NotFoundChallenge")) {
146
- if (!result.includes("export const sync")) {
147
- result += "\nexport const sync = true;\n";
148
- notes.push(`Added: export const sync = true (re-export: ${basename})`);
149
- changed = true;
150
- }
151
- }
152
-
153
- // Don't add LoadingFallback re-exports to thin section files
154
- // we can't guarantee the target component exports it.
155
- // Instead, if it's a listing section, a generic skeleton will be added below.
156
-
157
- // Generate a basic LoadingFallback if the section doesn't have one
158
- // and it's a listing section (visible skeleton improvement)
159
- if (sectionMeta.isListing && !sectionMeta.hasLoadingFallback && !result.includes("LoadingFallback")) {
160
- result += `
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
- notes.push("Added: LoadingFallback skeleton for listing section");
181
- changed = true;
182
- }
205
+ notes.push("Added: LoadingFallback skeleton for listing section");
206
+ changed = true;
207
+ }
183
208
 
184
- return { content: result, changed, notes };
209
+ return { content: result, changed, notes };
185
210
  }
@@ -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: { dryRun?: boolean; verbose?: boolean } = {},
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: {},
@@ -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 {