@decocms/blocks-cli 7.0.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.
Files changed (93) hide show
  1. package/package.json +44 -0
  2. package/scripts/analyze-traces.mjs +1117 -0
  3. package/scripts/audit-observability-config.test.ts +446 -0
  4. package/scripts/audit-observability-config.ts +511 -0
  5. package/scripts/deco-migrate-cli.ts +444 -0
  6. package/scripts/fast-deploy-kv.test.ts +131 -0
  7. package/scripts/generate-blocks.test.ts +94 -0
  8. package/scripts/generate-blocks.ts +274 -0
  9. package/scripts/generate-invoke.test.ts +195 -0
  10. package/scripts/generate-invoke.ts +469 -0
  11. package/scripts/generate-loaders.ts +217 -0
  12. package/scripts/generate-schema.ts +1287 -0
  13. package/scripts/generate-sections.ts +237 -0
  14. package/scripts/htmx-analyze.ts +226 -0
  15. package/scripts/lib/blocks-dedupe.test.ts +179 -0
  16. package/scripts/lib/blocks-dedupe.ts +142 -0
  17. package/scripts/lib/cf-kv-rest.ts +78 -0
  18. package/scripts/lib/jsonc.ts +122 -0
  19. package/scripts/lib/kv-snapshot.ts +51 -0
  20. package/scripts/lib/read-decofile.ts +70 -0
  21. package/scripts/lib/sync-helpers.ts +44 -0
  22. package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
  23. package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
  24. package/scripts/migrate/analyzers/island-classifier.ts +96 -0
  25. package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
  26. package/scripts/migrate/analyzers/section-metadata.ts +147 -0
  27. package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
  28. package/scripts/migrate/colors.ts +46 -0
  29. package/scripts/migrate/config.test.ts +202 -0
  30. package/scripts/migrate/config.ts +186 -0
  31. package/scripts/migrate/phase-analyze.test.ts +63 -0
  32. package/scripts/migrate/phase-analyze.ts +782 -0
  33. package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
  34. package/scripts/migrate/phase-cleanup-audit.ts +105 -0
  35. package/scripts/migrate/phase-cleanup.test.ts +141 -0
  36. package/scripts/migrate/phase-cleanup.ts +1588 -0
  37. package/scripts/migrate/phase-compile.test.ts +193 -0
  38. package/scripts/migrate/phase-compile.ts +177 -0
  39. package/scripts/migrate/phase-report.ts +243 -0
  40. package/scripts/migrate/phase-scaffold.ts +593 -0
  41. package/scripts/migrate/phase-transform.ts +310 -0
  42. package/scripts/migrate/phase-verify.test.ts +127 -0
  43. package/scripts/migrate/phase-verify.ts +572 -0
  44. package/scripts/migrate/post-cleanup/rules.ts +1708 -0
  45. package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
  46. package/scripts/migrate/post-cleanup/runner.ts +137 -0
  47. package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
  48. package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
  49. package/scripts/migrate/post-cleanup/types.ts +106 -0
  50. package/scripts/migrate/source-layout.test.ts +111 -0
  51. package/scripts/migrate/source-layout.ts +103 -0
  52. package/scripts/migrate/templates/app-css.ts +366 -0
  53. package/scripts/migrate/templates/cache-config.ts +26 -0
  54. package/scripts/migrate/templates/commerce-loaders.ts +230 -0
  55. package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
  56. package/scripts/migrate/templates/cursor-rules.ts +70 -0
  57. package/scripts/migrate/templates/hooks.test.ts +141 -0
  58. package/scripts/migrate/templates/hooks.ts +152 -0
  59. package/scripts/migrate/templates/knip-config.ts +27 -0
  60. package/scripts/migrate/templates/lib-utils.test.ts +139 -0
  61. package/scripts/migrate/templates/lib-utils.ts +326 -0
  62. package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
  63. package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
  64. package/scripts/migrate/templates/package-json.ts +177 -0
  65. package/scripts/migrate/templates/routes.ts +237 -0
  66. package/scripts/migrate/templates/sdk-gen.ts +59 -0
  67. package/scripts/migrate/templates/section-loaders.ts +456 -0
  68. package/scripts/migrate/templates/server-entry.ts +561 -0
  69. package/scripts/migrate/templates/setup.ts +148 -0
  70. package/scripts/migrate/templates/tsconfig.ts +21 -0
  71. package/scripts/migrate/templates/types-gen.ts +174 -0
  72. package/scripts/migrate/templates/ui-components.ts +144 -0
  73. package/scripts/migrate/templates/vite-config.ts +101 -0
  74. package/scripts/migrate/transforms/dead-code.ts +455 -0
  75. package/scripts/migrate/transforms/deno-isms.ts +85 -0
  76. package/scripts/migrate/transforms/fresh-apis.ts +223 -0
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
  78. package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
  79. package/scripts/migrate/transforms/imports.ts +385 -0
  80. package/scripts/migrate/transforms/jsx.ts +317 -0
  81. package/scripts/migrate/transforms/section-conventions.ts +210 -0
  82. package/scripts/migrate/transforms/tailwind.ts +739 -0
  83. package/scripts/migrate/types.ts +244 -0
  84. package/scripts/migrate-blocks-to-kv.ts +104 -0
  85. package/scripts/migrate-post-cleanup.ts +191 -0
  86. package/scripts/migrate-to-cf-observability.test.ts +215 -0
  87. package/scripts/migrate-to-cf-observability.ts +699 -0
  88. package/scripts/migrate.ts +282 -0
  89. package/scripts/smoke-otlp-errorlog.ts +46 -0
  90. package/scripts/smoke-otlp-meter.ts +48 -0
  91. package/scripts/sync-blocks-to-kv.ts +153 -0
  92. package/scripts/tailwind-lint.ts +518 -0
  93. package/tsconfig.json +7 -0
@@ -0,0 +1,147 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { MigrationContext, SectionMeta } from "../types";
4
+ import { log } from "../types";
5
+
6
+ const HEADER_RE = /\bheader\b/i;
7
+ const FOOTER_RE = /\bfooter\b/i;
8
+ const THEME_RE = /\btheme\b/i;
9
+ const LISTING_RE = /\b(?:shelf|carousel|slider|product\s*list|search\s*result)\b/i;
10
+
11
+ const LOADER_CONST_RE = /^export\s+const\s+loader\b/m;
12
+ const LOADER_FN_RE = /^export\s+(?:async\s+)?function\s+loader\b/m;
13
+ const LOADER_REEXPORT_RE = /^export\s*\{[^}]*\bloader\b[^}]*\}\s*from/m;
14
+ const LOADING_FALLBACK_RE = /^export\s+(?:const|function)\s+LoadingFallback\b|^export\s*\{[^}]*LoadingFallback[^}]*\}/m;
15
+ const JSDOC_TITLE_RE = /@title\b/;
16
+ const JSDOC_DESC_RE = /@description\b/;
17
+ const CTX_DEVICE_RE = /ctx\.device|useDevice|device.*(?:mobile|desktop)/i;
18
+ const DEVICE_PROP_RE_BROAD = /\bdevice\b.*(?:mobile|desktop|tablet)|\bisMobile\b|\bis_mobile\b/i;
19
+ const CTX_URL_RE = /ctx\.url|req\.url|ctx\.request|searchParam|pathname/i;
20
+ const ASYNC_RE = /^export\s+async\s+function\s+loader\b/m;
21
+ const STATUS_ONLY_RE = /ctx\.response\.status\s*=/;
22
+ const IS_MOBILE_RE = /isMobile|is_mobile|ctx\.device\s*===?\s*["']mobile["']/i;
23
+ const DEVICE_PROP_RE = /device\s*:\s*ctx\.device/;
24
+ const REEXPORT_FROM_RE = /^export\s*\{[^}]*\}\s*from\s*["']([^"']+)["']/m;
25
+
26
+ function isStatusOnlyLoader(content: string): boolean {
27
+ const loaderMatch = content.match(
28
+ /(?:export\s+const\s+loader\s*=|export\s+(?:async\s+)?function\s+loader)\s*[\s\S]*?\n(?=export\s|\z)/m,
29
+ );
30
+ if (!loaderMatch) return false;
31
+ const loaderBody = loaderMatch[0];
32
+ if (!STATUS_ONLY_RE.test(loaderBody)) return false;
33
+ const meaningful = loaderBody
34
+ .replace(/\/\/.*$/gm, "")
35
+ .replace(/\/\*[\s\S]*?\*\//g, "")
36
+ .replace(/ctx\.response\.status\s*=\s*\d+;?/g, "")
37
+ .replace(/return\s+props;?/g, "")
38
+ .replace(/if\s*\(props\.\w+\s*===?\s*null\)/g, "")
39
+ .replace(/export\s+(const|async\s+)?function\s+loader[^{]*\{/g, "")
40
+ .replace(/\};\s*$/g, "")
41
+ .trim();
42
+ return meaningful.replace(/[\s{}();,]/g, "").length < 30;
43
+ }
44
+
45
+ /**
46
+ * Resolve a re-export target path to an absolute file path.
47
+ * Handles `~/` prefix (mapped to `src/`) and relative paths.
48
+ */
49
+ function resolveReExportTarget(importPath: string, sectionAbsPath: string, sourceDir: string): string | null {
50
+ let resolved: string;
51
+ if (importPath.startsWith("~/")) {
52
+ resolved = path.join(sourceDir, "src", importPath.slice(2));
53
+ } else if (importPath.startsWith("./") || importPath.startsWith("../")) {
54
+ resolved = path.resolve(path.dirname(sectionAbsPath), importPath);
55
+ } else {
56
+ return null;
57
+ }
58
+
59
+ const candidates = [
60
+ resolved + ".tsx", resolved + ".ts",
61
+ path.join(resolved, "index.tsx"), path.join(resolved, "index.ts"),
62
+ resolved, // might already have extension
63
+ ];
64
+
65
+ for (const c of candidates) {
66
+ if (fs.existsSync(c)) return c;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Try to read the content of the component that a section barrel re-exports from.
73
+ * Returns null if the section is not a barrel or the target can't be found.
74
+ */
75
+ function readReExportTargetContent(content: string, sectionAbsPath: string, sourceDir: string): string | null {
76
+ const match = content.match(REEXPORT_FROM_RE);
77
+ if (!match) return null;
78
+
79
+ const targetPath = resolveReExportTarget(match[1], sectionAbsPath, sourceDir);
80
+ if (!targetPath) return null;
81
+
82
+ try {
83
+ return fs.readFileSync(targetPath, "utf-8");
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export function extractSectionMetadata(ctx: MigrationContext): void {
90
+ const sectionFiles = ctx.files.filter(
91
+ (f) => f.category === "section" && f.action !== "delete",
92
+ );
93
+
94
+ for (const file of sectionFiles) {
95
+ let content: string;
96
+ try {
97
+ content = fs.readFileSync(file.absPath, "utf-8");
98
+ } catch {
99
+ continue;
100
+ }
101
+
102
+ const basename = path.basename(file.path, path.extname(file.path));
103
+ const dirName = path.dirname(file.path).split("/").pop() || "";
104
+ const parentDirs = path.dirname(file.path).split("/");
105
+
106
+ const hasLoaderConst = LOADER_CONST_RE.test(content);
107
+ const hasLoaderFn = LOADER_FN_RE.test(content);
108
+ const hasLoaderReExport = LOADER_REEXPORT_RE.test(content);
109
+ const hasLoader = hasLoaderConst || hasLoaderFn || hasLoaderReExport;
110
+
111
+ const isAccountSection = parentDirs.some((d) => d.toLowerCase() === "account");
112
+
113
+ // For re-export barrels, also check the target component for device/url usage
114
+ const targetContent = readReExportTargetContent(content, file.absPath, ctx.sourceDir);
115
+ const combined = targetContent ? content + "\n" + targetContent : content;
116
+
117
+ const usesDevice = CTX_DEVICE_RE.test(combined) || DEVICE_PROP_RE_BROAD.test(combined);
118
+ const usesUrl = CTX_URL_RE.test(combined);
119
+ const usesMobile = IS_MOBILE_RE.test(combined) && !DEVICE_PROP_RE.test(combined);
120
+
121
+ const meta: SectionMeta = {
122
+ path: file.path,
123
+ hasLoader,
124
+ loaderIsAsync: hasLoader && ASYNC_RE.test(combined),
125
+ hasLoadingFallback: LOADING_FALLBACK_RE.test(content) || (targetContent ? LOADING_FALLBACK_RE.test(targetContent) : false),
126
+ isHeader: HEADER_RE.test(basename) || HEADER_RE.test(dirName),
127
+ isFooter: FOOTER_RE.test(basename) || FOOTER_RE.test(dirName),
128
+ isTheme: THEME_RE.test(basename) || THEME_RE.test(dirName),
129
+ isListing: LISTING_RE.test(basename) || LISTING_RE.test(dirName),
130
+ hasTitle: JSDOC_TITLE_RE.test(content),
131
+ hasDescription: JSDOC_DESC_RE.test(content),
132
+ loaderUsesDevice: usesDevice,
133
+ loaderUsesUrl: hasLoader && usesUrl,
134
+ isAccountSection,
135
+ isStatusOnly: hasLoader && isStatusOnlyLoader(content),
136
+ usesMobileBoolean: usesMobile,
137
+ };
138
+
139
+ ctx.sectionMetas.push(meta);
140
+ }
141
+
142
+ const withLoader = ctx.sectionMetas.filter((m) => m.hasLoader).length;
143
+ const layouts = ctx.sectionMetas.filter((m) => m.isHeader || m.isFooter || m.isTheme).length;
144
+ const accounts = ctx.sectionMetas.filter((m) => m.isAccountSection).length;
145
+ const statusOnly = ctx.sectionMetas.filter((m) => m.isStatusOnly).length;
146
+ log(ctx, `Sections analyzed: ${ctx.sectionMetas.length} total, ${withLoader} with loader, ${layouts} layout, ${accounts} account, ${statusOnly} status-only`);
147
+ }
@@ -0,0 +1,122 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { MigrationContext } from "../types";
4
+ import { log } from "../types";
5
+
6
+ export interface ExtractedTheme {
7
+ /** Raw CSS variable -> hex color map from DEFAULT_THEME */
8
+ variables: Record<string, string>;
9
+ /** Font family string (from Theme.tsx or default_theme) */
10
+ fontFamily: string | null;
11
+ /** DaisyUI semantic colors derived from the brand palette */
12
+ daisyUiColors: Record<string, string>;
13
+ }
14
+
15
+ const DAISYUI_MAPPING: Record<string, string[]> = {
16
+ "--color-primary": ["--brand-primary-1"],
17
+ "--color-secondary": ["--brand-secondary-1"],
18
+ "--color-accent": ["--brand-terciary-1", "--brand-terciary-base"],
19
+ "--color-neutral": ["--neutral-900", "--neutral-1"],
20
+ "--color-base-100": ["--neutral-0", "--neutral-50"],
21
+ "--color-base-200": ["--brand-secondary-50", "--neutral-100"],
22
+ "--color-base-300": ["--brand-secondary-500", "--neutral-500"],
23
+ "--color-info": ["--information"],
24
+ "--color-success": ["--success"],
25
+ "--color-warning": ["--warning"],
26
+ "--color-error": ["--error"],
27
+ };
28
+
29
+ function extractDefaultTheme(sourceDir: string): Record<string, string> | null {
30
+ const candidates = [
31
+ "styles/default_theme.ts",
32
+ "styles/defaultTheme.ts",
33
+ "sdk/default_theme.ts",
34
+ ];
35
+
36
+ for (const candidate of candidates) {
37
+ const filePath = path.join(sourceDir, candidate);
38
+ if (!fs.existsSync(filePath)) continue;
39
+
40
+ const content = fs.readFileSync(filePath, "utf-8");
41
+
42
+ const vars: Record<string, string> = {};
43
+ const entryRe = /["'](--.+?)["']\s*:\s*["'](.+?)["']/g;
44
+ let match: RegExpExecArray | null;
45
+ while ((match = entryRe.exec(content)) !== null) {
46
+ vars[match[1]] = match[2];
47
+ }
48
+
49
+ if (Object.keys(vars).length > 0) return vars;
50
+ }
51
+
52
+ return null;
53
+ }
54
+
55
+ function extractFontFamily(sourceDir: string): string | null {
56
+ const candidates = [
57
+ "sections/Theme/Theme.tsx",
58
+ "sections/theme/Theme.tsx",
59
+ ];
60
+
61
+ for (const candidate of candidates) {
62
+ const filePath = path.join(sourceDir, candidate);
63
+ if (!fs.existsSync(filePath)) continue;
64
+
65
+ const content = fs.readFileSync(filePath, "utf-8");
66
+
67
+ const fontMatch = content.match(
68
+ /["']--font-family["']\s*,\s*\n?\s*["'](.*?)["']/,
69
+ );
70
+ if (fontMatch) {
71
+ return fontMatch[1].split(",")[0].trim();
72
+ }
73
+
74
+ const fontMatch2 = content.match(
75
+ /font.*?["']([\w\s]+(?:,\s*[\w\s-]+)*)/i,
76
+ );
77
+ if (fontMatch2) {
78
+ const family = fontMatch2[1].split(",")[0].trim();
79
+ if (family && family !== "sans-serif") return family;
80
+ }
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ function deriveDaisyUiColors(vars: Record<string, string>): Record<string, string> {
87
+ const result: Record<string, string> = {};
88
+
89
+ for (const [daisyKey, sourceKeys] of Object.entries(DAISYUI_MAPPING)) {
90
+ for (const sourceKey of sourceKeys) {
91
+ if (vars[sourceKey]) {
92
+ result[daisyKey] = vars[sourceKey];
93
+ break;
94
+ }
95
+ }
96
+ }
97
+
98
+ return result;
99
+ }
100
+
101
+ export function extractTheme(ctx: MigrationContext): ExtractedTheme {
102
+ const vars = extractDefaultTheme(ctx.sourceDir);
103
+
104
+ if (!vars) {
105
+ log(ctx, "No styles/default_theme.ts found — using placeholder theme");
106
+ return {
107
+ variables: {},
108
+ fontFamily: ctx.fontFamily,
109
+ daisyUiColors: {},
110
+ };
111
+ }
112
+
113
+ const fontFamily = extractFontFamily(ctx.sourceDir) || ctx.fontFamily;
114
+ const daisyUiColors = deriveDaisyUiColors(vars);
115
+
116
+ log(
117
+ ctx,
118
+ `Theme extracted: ${Object.keys(vars).length} variables, ${Object.keys(daisyUiColors).length} DaisyUI colors, font: ${fontFamily || "none"}`,
119
+ );
120
+
121
+ return { variables: vars, fontFamily, daisyUiColors };
122
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Terminal color utilities — keeps output readable without adding dependencies.
3
+ */
4
+
5
+ const isColorSupported =
6
+ typeof process !== "undefined" &&
7
+ process.stdout?.isTTY &&
8
+ !process.env.NO_COLOR;
9
+
10
+ function wrap(code: number, resetCode: number) {
11
+ return (text: string) =>
12
+ isColorSupported ? `\x1b[${code}m${text}\x1b[${resetCode}m` : text;
13
+ }
14
+
15
+ export const bold = wrap(1, 22);
16
+ export const dim = wrap(2, 22);
17
+ export const red = wrap(31, 39);
18
+ export const green = wrap(32, 39);
19
+ export const yellow = wrap(33, 39);
20
+ export const blue = wrap(34, 39);
21
+ export const cyan = wrap(36, 39);
22
+ export const gray = wrap(90, 39);
23
+
24
+ export const icons = {
25
+ success: isColorSupported ? "\x1b[32m✓\x1b[0m" : "[OK]",
26
+ error: isColorSupported ? "\x1b[31m✗\x1b[0m" : "[FAIL]",
27
+ warning: isColorSupported ? "\x1b[33m⚠\x1b[0m" : "[WARN]",
28
+ info: isColorSupported ? "\x1b[34mℹ\x1b[0m" : "[INFO]",
29
+ arrow: isColorSupported ? "\x1b[36m→\x1b[0m" : "->",
30
+ bullet: isColorSupported ? "\x1b[90m•\x1b[0m" : "-",
31
+ };
32
+
33
+ export function banner(text: string) {
34
+ const line = "═".repeat(58);
35
+ console.log(`\n${cyan(`╔${line}╗`)}`);
36
+ console.log(`${cyan("║")} ${bold(text.padEnd(56))}${cyan("║")}`);
37
+ console.log(`${cyan(`╚${line}╝`)}`);
38
+ }
39
+
40
+ export function phase(name: string) {
41
+ console.log(`\n${bold(blue(`━━━ ${name} ━━━`))}\n`);
42
+ }
43
+
44
+ export function stat(label: string, value: string | number) {
45
+ console.log(` ${gray(label + ":")} ${bold(String(value))}`);
46
+ }
@@ -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
+ }