@decocms/blocks-cli 8.0.0 → 8.1.0-next.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/package.json +5 -3
- package/scripts/analyze-traces.mjs +1 -0
- package/scripts/cdn-rules.test.ts +105 -0
- package/scripts/cdn-rules.ts +192 -0
- package/scripts/deco-migrate-cli.ts +6 -7
- package/scripts/fast-deploy-kv.test.ts +172 -0
- package/scripts/generate-blocks.test.ts +11 -0
- package/scripts/generate-blocks.ts +0 -10
- package/scripts/generate-invoke.test.ts +2 -2
- package/scripts/generate-invoke.ts +9 -21
- package/scripts/generate-loaders.test.ts +2 -2
- package/scripts/generate-loaders.ts +0 -10
- package/scripts/generate-schema-loader-aliases.test.ts +186 -0
- package/scripts/generate-schema-workspaces.test.ts +180 -0
- package/scripts/generate-schema.test.ts +68 -29
- package/scripts/generate-schema.ts +101 -89
- package/scripts/generate-sections.test.ts +104 -35
- package/scripts/generate-sections.ts +57 -11
- package/scripts/generate-workspaces.test.ts +179 -0
- package/scripts/generate.test.ts +1 -2
- package/scripts/generate.ts +43 -45
- package/scripts/lib/blocks-dedupe.test.ts +1 -1
- package/scripts/lib/cf-kv-rest.ts +49 -1
- package/scripts/lib/installedPackages.ts +42 -0
- package/scripts/lib/invokeSource.ts +18 -0
- package/scripts/lib/kv-snapshot.ts +44 -0
- package/scripts/lib/read-decofile.ts +13 -2
- package/scripts/lib/upgrade-6-to-7.ts +1 -1
- package/scripts/lib/wrangler-config.test.ts +49 -0
- package/scripts/lib/wrangler-config.ts +31 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
- package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
- package/scripts/migrate/config.ts +2 -2
- package/scripts/migrate/delete-sets.ts +75 -0
- package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
- package/scripts/migrate/icon-casing.test.ts +63 -0
- package/scripts/migrate/phase-analyze.test.ts +66 -4
- package/scripts/migrate/phase-analyze.ts +79 -60
- package/scripts/migrate/phase-cleanup.test.ts +3 -1
- package/scripts/migrate/phase-cleanup.ts +1 -1
- package/scripts/migrate/phase-compile.test.ts +3 -1
- package/scripts/migrate/phase-report.test.ts +2 -0
- package/scripts/migrate/phase-report.ts +3 -4
- package/scripts/migrate/phase-scaffold.ts +150 -45
- package/scripts/migrate/phase-transform.ts +107 -2
- package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
- package/scripts/migrate/phase-verify.test.ts +3 -1
- package/scripts/migrate/phase-verify.ts +87 -33
- package/scripts/migrate/post-cleanup/rules.ts +29 -11
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
- package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
- package/scripts/migrate/source-layout.test.ts +43 -0
- package/scripts/migrate/source-layout.ts +32 -5
- package/scripts/migrate/templates/app-css.test.ts +24 -0
- package/scripts/migrate/templates/app-css.ts +27 -0
- package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
- package/scripts/migrate/templates/ci-yml.ts +182 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
- package/scripts/migrate/templates/hooks.test.ts +3 -1
- package/scripts/migrate/templates/lib-utils.ts +3 -3
- package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
- package/scripts/migrate/templates/package-json.ts +10 -0
- package/scripts/migrate/templates/parity-yml.ts +136 -0
- package/scripts/migrate/templates/perf-yml.ts +449 -0
- package/scripts/migrate/templates/playwright-yml.ts +127 -0
- package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
- package/scripts/migrate/templates/routes.test.ts +31 -0
- package/scripts/migrate/templates/routes.ts +19 -9
- package/scripts/migrate/templates/section-loaders.ts +3 -3
- package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
- package/scripts/migrate/templates/server-entry.ts +19 -14
- package/scripts/migrate/templates/setup.ts +11 -3
- package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
- package/scripts/migrate/templates/ui-components.ts +3 -0
- package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
- package/scripts/migrate/transforms/ctx-compat.ts +93 -1
- package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
- package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
- package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
- package/scripts/migrate/transforms/jsx.ts +10 -0
- package/scripts/migrate/transforms/picture.test.ts +56 -0
- package/scripts/migrate/transforms/picture.ts +95 -0
- package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
- package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
- package/scripts/migrate/transforms/timer-types.test.ts +30 -0
- package/scripts/migrate/transforms/timer-types.ts +48 -0
- package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
- package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
- package/scripts/migrate/types.ts +13 -1
- package/scripts/migrate-blocks-to-kv.ts +16 -2
- package/scripts/migrate-to-cf-observability.test.ts +5 -5
- package/scripts/migrate-to-cf-observability.ts +1 -1
- package/scripts/migrate.ts +244 -6
- package/scripts/reconcile.test.ts +162 -0
- package/scripts/reconcile.ts +303 -0
- package/scripts/sync-blocks-bot.test.ts +276 -0
- package/scripts/sync-blocks-bot.ts +567 -0
- package/scripts/sync-blocks-to-kv.ts +20 -2
- package/scripts/upgrade-6-to-7.ts +0 -0
- package/scripts/lib/legacyArtifact.ts +0 -20
|
@@ -72,3 +72,34 @@ function matchTomlString(body: string, key: string): string | null {
|
|
|
72
72
|
const m = body.match(new RegExp(`(?:^|\\n)\\s*${key}\\s*=\\s*["']([^"']+)["']`));
|
|
73
73
|
return m ? m[1] : null;
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Set the `id` of a `kv_namespaces` entry in a `wrangler.jsonc` SOURCE string,
|
|
78
|
+
* preserving comments and formatting.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately text-level rather than parse-and-restringify: the scaffolded
|
|
81
|
+
* `wrangler.jsonc` carries the comments explaining why each binding exists, and
|
|
82
|
+
* a round-trip through `JSON.parse`/`stringify` would drop every one of them.
|
|
83
|
+
*
|
|
84
|
+
* Returns the original string unchanged when the binding is absent — callers
|
|
85
|
+
* treat that as "nothing to personalize", never as an error, because the
|
|
86
|
+
* builder re-forces the id from `CF_KV_NAMESPACE_ID` on every build anyway.
|
|
87
|
+
*/
|
|
88
|
+
export function setKvNamespaceIdInJsonc(
|
|
89
|
+
src: string,
|
|
90
|
+
id: string,
|
|
91
|
+
binding = DEFAULT_BINDING,
|
|
92
|
+
): string {
|
|
93
|
+
// Match the object literal holding this binding, in either field order, and
|
|
94
|
+
// rewrite only its `id` value. Bounded to a single `{...}` so it cannot reach
|
|
95
|
+
// past the entry into the next one.
|
|
96
|
+
const entry = new RegExp(`\\{[^{}]*?"binding"\\s*:\\s*"${binding}"[^{}]*?\\}`, "s");
|
|
97
|
+
const m = src.match(entry);
|
|
98
|
+
if (!m) return src;
|
|
99
|
+
|
|
100
|
+
const patched = m[0].includes('"id"')
|
|
101
|
+
? m[0].replace(/"id"\s*:\s*"[^"]*"/, `"id": ${JSON.stringify(id)}`)
|
|
102
|
+
: m[0].replace(/\}$/, `, "id": ${JSON.stringify(id)} }`);
|
|
103
|
+
|
|
104
|
+
return src.slice(0, m.index) + patched + src.slice(m.index! + m[0].length);
|
|
105
|
+
}
|
|
@@ -116,7 +116,7 @@ describe("classify (pure)", () => {
|
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
it("treats hx-on with a fetch attr as click-swap (the fetch wins)", () => {
|
|
119
|
-
// Real example from
|
|
119
|
+
// Real example from a production htmx storefront (EmailAndPassword button): hx-get +
|
|
120
120
|
// hx-target + hx-trigger=click — engineer often piles
|
|
121
121
|
// hx-on alongside, but the dominant migration path is the
|
|
122
122
|
// click-swap recipe.
|
|
@@ -127,11 +127,11 @@ describe("classify (pure)", () => {
|
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
/* ------------------------------------------------------------------ */
|
|
130
|
-
/* analyzeFile() — JSX walker tests using real
|
|
130
|
+
/* analyzeFile() — JSX walker tests using real htmx-shaped fixtures */
|
|
131
131
|
/* ------------------------------------------------------------------ */
|
|
132
132
|
|
|
133
133
|
describe("analyzeFile (real shapes)", () => {
|
|
134
|
-
it("detects hx-on:click={useScript(...)} click handler (
|
|
134
|
+
it("detects hx-on:click={useScript(...)} click handler (AddToBagButton shape)", () => {
|
|
135
135
|
const file = `
|
|
136
136
|
import { useScript } from "@deco/deco/hooks";
|
|
137
137
|
export default function AddToBagButton() {
|
|
@@ -157,7 +157,7 @@ export default function AddToBagButton() {
|
|
|
157
157
|
expect(out[0].attrs).toEqual(["hx-on:click"]);
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
-
it("detects hx-post + hx-target + hx-trigger=keyup on an input as auto-fetch (
|
|
160
|
+
it("detects hx-post + hx-target + hx-trigger=keyup on an input as auto-fetch (SearchInput shape)", () => {
|
|
161
161
|
const file = `
|
|
162
162
|
<input
|
|
163
163
|
id={searchInputId}
|
|
@@ -181,7 +181,7 @@ export default function AddToBagButton() {
|
|
|
181
181
|
expect(out[0].attrs).toContain("hx-target");
|
|
182
182
|
});
|
|
183
183
|
|
|
184
|
-
it("detects hx-post + hx-target + hx-swap on a form as form-swap (
|
|
184
|
+
it("detects hx-post + hx-target + hx-swap on a form as form-swap (EmailAndPassword shape)", () => {
|
|
185
185
|
const file = `
|
|
186
186
|
<form
|
|
187
187
|
class="flex flex-col w-full"
|
|
@@ -202,7 +202,7 @@ export default function AddToBagButton() {
|
|
|
202
202
|
expect(out[0].attrs).toContain("hx-target");
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
-
it("detects hx-get on a button as click-swap (
|
|
205
|
+
it("detects hx-get on a button as click-swap (ForgotPassword shape)", () => {
|
|
206
206
|
const file = `
|
|
207
207
|
<button
|
|
208
208
|
type="button"
|
|
@@ -24,6 +24,10 @@ export interface TailwindConfigExtract {
|
|
|
24
24
|
fontFamily: Record<string, string>;
|
|
25
25
|
/** Custom breakpoints, e.g. "3xl" -> "1920px" */
|
|
26
26
|
screens: Record<string, string>;
|
|
27
|
+
/** Custom animations, e.g. "fade-in" -> "fadeIn 0.5s ease-in" */
|
|
28
|
+
animations: Record<string, string>;
|
|
29
|
+
/** Custom keyframe stops, e.g. "fadeIn" -> { "0%": "opacity: 0", "100%": "opacity: 1" } */
|
|
30
|
+
keyframes: Record<string, Record<string, string>>;
|
|
27
31
|
/** Literal safelist entries (string class names) */
|
|
28
32
|
safelist: string[];
|
|
29
33
|
/** Safelist regex patterns, kept as their source text */
|
|
@@ -40,6 +44,8 @@ function emptyExtract(): TailwindConfigExtract {
|
|
|
40
44
|
colors: {},
|
|
41
45
|
fontFamily: {},
|
|
42
46
|
screens: {},
|
|
47
|
+
animations: {},
|
|
48
|
+
keyframes: {},
|
|
43
49
|
safelist: [],
|
|
44
50
|
safelistPatterns: [],
|
|
45
51
|
plugins: [],
|
|
@@ -184,6 +190,84 @@ function extractScreens(obj: ObjectLiteralExpression): Record<string, string> {
|
|
|
184
190
|
return result;
|
|
185
191
|
}
|
|
186
192
|
|
|
193
|
+
function extractAnimations(
|
|
194
|
+
obj: ObjectLiteralExpression,
|
|
195
|
+
reviewItems: ReviewItem[],
|
|
196
|
+
): Record<string, string> {
|
|
197
|
+
const result: Record<string, string> = {};
|
|
198
|
+
for (const prop of obj.getProperties()) {
|
|
199
|
+
if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
200
|
+
const name = stripQuotes(prop.getName());
|
|
201
|
+
const init = prop.getInitializer();
|
|
202
|
+
if (!init) continue;
|
|
203
|
+
if (
|
|
204
|
+
init.isKind(SyntaxKind.StringLiteral) ||
|
|
205
|
+
init.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
|
|
206
|
+
) {
|
|
207
|
+
result[name] = init.getLiteralText();
|
|
208
|
+
} else {
|
|
209
|
+
reviewItems.push({
|
|
210
|
+
file: CONFIG_REL_PATH,
|
|
211
|
+
reason: `theme.extend.animation.${name} is not a static string literal (${init.getKindName()}) — port manually to @theme { --animate-${name}: ...; } in src/styles/app.css`,
|
|
212
|
+
severity: "warning",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function extractKeyframes(
|
|
220
|
+
obj: ObjectLiteralExpression,
|
|
221
|
+
reviewItems: ReviewItem[],
|
|
222
|
+
): Record<string, Record<string, string>> {
|
|
223
|
+
const result: Record<string, Record<string, string>> = {};
|
|
224
|
+
for (const frameProp of obj.getProperties()) {
|
|
225
|
+
if (!frameProp.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
226
|
+
const frameName = stripQuotes(frameProp.getName());
|
|
227
|
+
const frameInit = frameProp.getInitializer();
|
|
228
|
+
if (!frameInit?.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
229
|
+
reviewItems.push({
|
|
230
|
+
file: CONFIG_REL_PATH,
|
|
231
|
+
reason: `theme.extend.keyframes.${frameName} is not an object literal — port manually to @keyframes ${frameName} { ... } in src/styles/app.css`,
|
|
232
|
+
severity: "warning",
|
|
233
|
+
});
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const stops: Record<string, string> = {};
|
|
237
|
+
for (const stopProp of frameInit.getProperties()) {
|
|
238
|
+
if (!stopProp.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
239
|
+
const stopKey = stripQuotes(stopProp.getName()); // "0%", "100%", "from", "to"
|
|
240
|
+
const stopInit = stopProp.getInitializer();
|
|
241
|
+
if (!stopInit?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
|
|
242
|
+
const cssParts: string[] = [];
|
|
243
|
+
for (const cssProp of stopInit.getProperties()) {
|
|
244
|
+
if (!cssProp.isKind(SyntaxKind.PropertyAssignment)) continue;
|
|
245
|
+
const cssName = stripQuotes(cssProp.getName());
|
|
246
|
+
const cssVal = cssProp.getInitializer();
|
|
247
|
+
if (
|
|
248
|
+
cssVal?.isKind(SyntaxKind.StringLiteral) ||
|
|
249
|
+
cssVal?.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
|
|
250
|
+
) {
|
|
251
|
+
// camelCase -> kebab-case (e.g. "transformOrigin" -> "transform-origin")
|
|
252
|
+
const kebab = cssName.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
253
|
+
cssParts.push(`${kebab}: ${cssVal.getLiteralText()}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (cssParts.length > 0) {
|
|
257
|
+
stops[stopKey] = cssParts.join("; ");
|
|
258
|
+
} else {
|
|
259
|
+
reviewItems.push({
|
|
260
|
+
file: CONFIG_REL_PATH,
|
|
261
|
+
reason: `theme.extend.keyframes.${frameName}["${stopKey}"] has no static string literal properties — port this keyframe stop manually to @keyframes ${frameName} { ... } in src/styles/app.css`,
|
|
262
|
+
severity: "warning",
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (Object.keys(stops).length > 0) result[frameName] = stops;
|
|
267
|
+
}
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
|
|
187
271
|
function extractSafelist(
|
|
188
272
|
arr: import("ts-morph").ArrayLiteralExpression,
|
|
189
273
|
reviewItems: ReviewItem[],
|
|
@@ -258,6 +342,16 @@ export function extractTailwindConfig(sourceDir: string): TailwindConfigExtract
|
|
|
258
342
|
(themeObj && getObjectChild(themeObj, "screens"));
|
|
259
343
|
const screens = screensObj ? extractScreens(screensObj) : {};
|
|
260
344
|
|
|
345
|
+
const animationObj =
|
|
346
|
+
(extendObj && getObjectChild(extendObj, "animation")) ||
|
|
347
|
+
(themeObj && getObjectChild(themeObj, "animation"));
|
|
348
|
+
const animations = animationObj ? extractAnimations(animationObj, reviewItems) : {};
|
|
349
|
+
|
|
350
|
+
const keyframesObj =
|
|
351
|
+
(extendObj && getObjectChild(extendObj, "keyframes")) ||
|
|
352
|
+
(themeObj && getObjectChild(themeObj, "keyframes"));
|
|
353
|
+
const keyframes = keyframesObj ? extractKeyframes(keyframesObj, reviewItems) : {};
|
|
354
|
+
|
|
261
355
|
let safelist: string[] = [];
|
|
262
356
|
let safelistPatterns: string[] = [];
|
|
263
357
|
const safelistProp = root.getProperty("safelist");
|
|
@@ -279,5 +373,5 @@ export function extractTailwindConfig(sourceDir: string): TailwindConfigExtract
|
|
|
279
373
|
}
|
|
280
374
|
}
|
|
281
375
|
|
|
282
|
-
return { colors, fontFamily, screens, safelist, safelistPatterns, plugins, reviewItems };
|
|
376
|
+
return { colors, fontFamily, screens, animations, keyframes, safelist, safelistPatterns, plugins, reviewItems };
|
|
283
377
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Looks for `.deco-migrate.config.json` next to the source root. The file
|
|
5
5
|
* is optional — without it the script falls back to a baked-in default set
|
|
6
|
-
* of section-convention names that work for `
|
|
6
|
+
* of section-convention names that work for `acme` and most other
|
|
7
7
|
* Deco/VTEX sites that derived from the same template.
|
|
8
8
|
*
|
|
9
9
|
* The defaults are kept here (not in `transforms/section-conventions.ts`)
|
|
@@ -61,7 +61,7 @@ export interface SectionConventionSets {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
* Built-in defaults. Originally extracted from
|
|
64
|
+
* Built-in defaults. Originally extracted from a production migration —
|
|
65
65
|
* these names are common across Deco/VTEX storefronts that share the
|
|
66
66
|
* lineage. Sites that don't have these sections are unaffected (the
|
|
67
67
|
* matcher just never fires).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paths the migration deletes outright. Single source of truth: `decideAction`
|
|
3
|
+
* (phase-analyze) decides with them, and `deco-reconcile` filters with them — an
|
|
4
|
+
* upstream change to a file the migration deleted has no target equivalent, so
|
|
5
|
+
* reporting it just makes the agent consider migrating a file that must not exist.
|
|
6
|
+
*
|
|
7
|
+
* Only the unambiguous sets live here. Rules that are conditional on context
|
|
8
|
+
* (routes/ and apps/ are rescaffolded, root-level docs) stay in `decideAction`:
|
|
9
|
+
* reconcile must stay conservative, since dropping a real upstream change is a
|
|
10
|
+
* worse failure than showing one file too many.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Files that are generated and should be deleted */
|
|
14
|
+
export const GENERATED_FILES = new Set([
|
|
15
|
+
"fresh.gen.ts",
|
|
16
|
+
"manifest.gen.ts",
|
|
17
|
+
"fresh.config.ts",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/** SDK files that have framework equivalents or are scaffolded fresh */
|
|
21
|
+
export const SDK_DELETE = new Set([
|
|
22
|
+
"sdk/clx.ts",
|
|
23
|
+
"sdk/useId.ts",
|
|
24
|
+
// sdk/useOffer.ts — kept: sites often customize offer logic
|
|
25
|
+
// sdk/useVariantPossiblities.ts — kept: sites often customize variant logic
|
|
26
|
+
"sdk/usePlatform.tsx",
|
|
27
|
+
"sdk/signal.ts",
|
|
28
|
+
"sdk/format.ts",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/** Component files that are scaffolded fresh (old versions must not overwrite) */
|
|
32
|
+
export const COMPONENT_DELETE = new Set([
|
|
33
|
+
"components/ui/Image.tsx",
|
|
34
|
+
"components/ui/Picture.tsx",
|
|
35
|
+
"components/ui/Video.tsx",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/** Loaders that depend on deleted admin tooling */
|
|
39
|
+
export const LOADER_DELETE = new Set([
|
|
40
|
+
"loaders/availableIcons.ts",
|
|
41
|
+
"loaders/icons.ts",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** Root config/infra files to delete */
|
|
45
|
+
export const ROOT_DELETE = new Set([
|
|
46
|
+
"main.ts",
|
|
47
|
+
"dev.ts",
|
|
48
|
+
"deno.json",
|
|
49
|
+
"deno.lock",
|
|
50
|
+
"tailwind.css",
|
|
51
|
+
"tailwind.config.ts",
|
|
52
|
+
"runtime.ts",
|
|
53
|
+
"constants.ts",
|
|
54
|
+
"fresh.gen.ts",
|
|
55
|
+
"manifest.gen.ts",
|
|
56
|
+
"fresh.config.ts",
|
|
57
|
+
"browserslist",
|
|
58
|
+
"bw_stats.json",
|
|
59
|
+
"islands.ts",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/** Static files that are code/tooling, not assets — should be deleted */
|
|
63
|
+
export const STATIC_DELETE = new Set([
|
|
64
|
+
"static/adminIcons.ts",
|
|
65
|
+
"static/generate-icons.ts",
|
|
66
|
+
"static/tailwind.css",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/** True when the migration deletes this source path outright. */
|
|
70
|
+
export function isDeletedByMigration(relPath: string): boolean {
|
|
71
|
+
return GENERATED_FILES.has(relPath) || ROOT_DELETE.has(relPath) ||
|
|
72
|
+
SDK_DELETE.has(relPath) || COMPONENT_DELETE.has(relPath) ||
|
|
73
|
+
LOADER_DELETE.has(relPath) || STATIC_DELETE.has(relPath) ||
|
|
74
|
+
relPath.startsWith("sdk/cart/") || relPath.startsWith("apps/deco/");
|
|
75
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { parseJsonc } from "../lib/jsonc";
|
|
6
|
+
import { generateWranglerConfig } from "./phase-scaffold";
|
|
7
|
+
import { checks } from "./phase-verify";
|
|
8
|
+
import { generateSetup } from "./templates/setup";
|
|
9
|
+
import type { MigrationContext } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fast Deploy is inert unless THREE things line up: the `DECO_KV` binding,
|
|
13
|
+
* `DECO_FAST_DEPLOY=1`, and `setup.ts` handing the KV resolver to
|
|
14
|
+
* `@decocms/blocks-admin` (which cannot import `@decocms/tanstack` itself).
|
|
15
|
+
*
|
|
16
|
+
* Two of three is the worst state: it looks configured, a Studio publish
|
|
17
|
+
* reports success, and nothing ever reaches KV. These tests pin the scaffold to
|
|
18
|
+
* emit all three and pin the verify check to actually catch a partial config.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const FIXTURE: MigrationContext = {
|
|
22
|
+
sourceDir: "",
|
|
23
|
+
siteName: "test-site",
|
|
24
|
+
platform: "custom",
|
|
25
|
+
vtexAccount: null,
|
|
26
|
+
gtmId: null,
|
|
27
|
+
importMap: {},
|
|
28
|
+
discoveredNpmDeps: {},
|
|
29
|
+
themeColors: {},
|
|
30
|
+
tailwindConfig: {
|
|
31
|
+
colors: {},
|
|
32
|
+
fontFamily: {},
|
|
33
|
+
screens: {},
|
|
34
|
+
safelist: [],
|
|
35
|
+
safelistPatterns: [],
|
|
36
|
+
plugins: [],
|
|
37
|
+
reviewItems: [],
|
|
38
|
+
animations: {},
|
|
39
|
+
keyframes: {},
|
|
40
|
+
},
|
|
41
|
+
fontFamily: null,
|
|
42
|
+
googleFonts: [],
|
|
43
|
+
layout: null,
|
|
44
|
+
files: [],
|
|
45
|
+
sectionMetas: [],
|
|
46
|
+
islandClassifications: [],
|
|
47
|
+
loaderInventory: [],
|
|
48
|
+
patterns: {},
|
|
49
|
+
scaffoldedFiles: [],
|
|
50
|
+
transformedFiles: [],
|
|
51
|
+
deletedFiles: [],
|
|
52
|
+
movedFiles: [],
|
|
53
|
+
manualReview: [],
|
|
54
|
+
dryRun: false,
|
|
55
|
+
verbose: false,
|
|
56
|
+
} as unknown as MigrationContext;
|
|
57
|
+
|
|
58
|
+
const fastDeployCheck = checks.find((c) => c.name === "Fast Deploy wired end to end");
|
|
59
|
+
|
|
60
|
+
/** Write a site tree with the given wrangler + setup contents. */
|
|
61
|
+
function makeSite(wrangler: string, setup: string): MigrationContext {
|
|
62
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fd-scaffold-"));
|
|
63
|
+
fs.writeFileSync(path.join(dir, "wrangler.jsonc"), wrangler);
|
|
64
|
+
fs.mkdirSync(path.join(dir, "src"), { recursive: true });
|
|
65
|
+
fs.writeFileSync(path.join(dir, "src", "setup.ts"), setup);
|
|
66
|
+
return { ...FIXTURE, sourceDir: dir };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("scaffolded wrangler.jsonc", () => {
|
|
70
|
+
// Parse it rather than grep it: the template is hand-written JSONC, and a
|
|
71
|
+
// stray comma or comment that breaks parsing silently disables fast-deploy
|
|
72
|
+
// everywhere at once (the builder, the control-plane and the verify check
|
|
73
|
+
// below all read this file through a JSONC parser).
|
|
74
|
+
const cfg = parseJsonc(generateWranglerConfig(FIXTURE)) as {
|
|
75
|
+
kv_namespaces: Array<{ binding: string }>;
|
|
76
|
+
vars: Record<string, string>;
|
|
77
|
+
tail_consumers?: unknown[];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
it("declares the DECO_KV binding and DECO_FAST_DEPLOY together", () => {
|
|
81
|
+
expect(cfg.kv_namespaces.map((n) => n.binding)).toContain("DECO_KV");
|
|
82
|
+
expect(cfg.vars.DECO_FAST_DEPLOY).toBe("1");
|
|
83
|
+
expect(cfg.vars.DECO_SITE_NAME).toBe("test-site");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("declares a tail consumer, without which exceededMemory is unobservable", () => {
|
|
87
|
+
// Cloudflare kills the isolate over the 128MB cap before any in-worker code
|
|
88
|
+
// could report it — the tail worker is the only channel that sees it.
|
|
89
|
+
expect(cfg.tail_consumers).toBeTruthy();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("scaffolded setup.ts", () => {
|
|
94
|
+
it("calls setupTanstackFastDeploy, or the Studio publish write-through no-ops", () => {
|
|
95
|
+
const setup = generateSetup(FIXTURE);
|
|
96
|
+
expect(setup).toContain("setupTanstackFastDeploy()");
|
|
97
|
+
expect(setup).toMatch(/import \{[^}]*setupTanstackFastDeploy[^}]*\} from "@decocms\/tanstack"/);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('verify check "Fast Deploy wired end to end"', () => {
|
|
102
|
+
const FULL_WRANGLER = `{
|
|
103
|
+
// comment + trailing comma: real wrangler.jsonc, not JSON
|
|
104
|
+
"kv_namespaces": [{ "binding": "DECO_KV", "id": "" }],
|
|
105
|
+
"vars": { "DECO_FAST_DEPLOY": "1" },
|
|
106
|
+
}`;
|
|
107
|
+
const FULL_SETUP = "setupTanstackFastDeploy();\n";
|
|
108
|
+
|
|
109
|
+
it("passes on a fully wired site", () => {
|
|
110
|
+
expect(fastDeployCheck?.fn(makeSite(FULL_WRANGLER, FULL_SETUP))).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("fails when the DECO_KV binding is missing", () => {
|
|
114
|
+
const w = '{ "kv_namespaces": [], "vars": { "DECO_FAST_DEPLOY": "1" } }';
|
|
115
|
+
expect(fastDeployCheck?.fn(makeSite(w, FULL_SETUP))).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("fails when DECO_FAST_DEPLOY is not set", () => {
|
|
119
|
+
const w = '{ "kv_namespaces": [{ "binding": "DECO_KV", "id": "" }] }';
|
|
120
|
+
expect(fastDeployCheck?.fn(makeSite(w, FULL_SETUP))).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("fails when setup.ts never hands over the KV resolver", () => {
|
|
124
|
+
expect(fastDeployCheck?.fn(makeSite(FULL_WRANGLER, "// nothing\n"))).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("is an error, not a warning — a silent half-config is the failure mode", () => {
|
|
128
|
+
expect(fastDeployCheck?.severity).toBe("error");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { reconcileIconCasing } from "./phase-transform";
|
|
6
|
+
import { createContext, type MigrationContext } from "./types";
|
|
7
|
+
|
|
8
|
+
let dir: string;
|
|
9
|
+
let ctx: MigrationContext;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
dir = mkdtempSync(join(tmpdir(), "icon-casing-"));
|
|
13
|
+
mkdirSync(join(dir, "src", "components", "ui"), { recursive: true });
|
|
14
|
+
mkdirSync(join(dir, "src", "sections"), { recursive: true });
|
|
15
|
+
writeFileSync(
|
|
16
|
+
join(dir, "src", "components", "ui", "Icon.tsx"),
|
|
17
|
+
`export type AvailableIcons =\n | "Close"\n | "Menu"\n | "ChevronDown";\n`,
|
|
18
|
+
);
|
|
19
|
+
ctx = createContext(dir);
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
22
|
+
|
|
23
|
+
describe("reconcileIconCasing", () => {
|
|
24
|
+
it("case-corrects <Icon id> to match the declared AvailableIcons", () => {
|
|
25
|
+
const f = join(dir, "src", "sections", "Drawer.tsx");
|
|
26
|
+
writeFileSync(f, `<Icon id="close" /> <Icon id="menu" /> <Icon id="Close" />`);
|
|
27
|
+
reconcileIconCasing(ctx);
|
|
28
|
+
const out = readFileSync(f, "utf8");
|
|
29
|
+
expect(out).toContain(`id="Close"`);
|
|
30
|
+
expect(out).toContain(`id="Menu"`);
|
|
31
|
+
expect(out).not.toContain(`id="close"`);
|
|
32
|
+
expect(out).not.toContain(`id="menu"`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("flags a used icon that is NOT in AvailableIcons (no blind rename)", () => {
|
|
36
|
+
const f = join(dir, "src", "sections", "Search.tsx");
|
|
37
|
+
writeFileSync(f, `<Icon id="search" />`);
|
|
38
|
+
reconcileIconCasing(ctx);
|
|
39
|
+
// left as-is
|
|
40
|
+
expect(readFileSync(f, "utf8")).toContain(`id="search"`);
|
|
41
|
+
// flagged for manual review, once
|
|
42
|
+
const flags = ctx.manualReviewItems.filter((m) => m.reason.includes(`id="search"`));
|
|
43
|
+
expect(flags).toHaveLength(1);
|
|
44
|
+
expect(flags[0]!.file).toBe("src/sections/Search.tsx");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("only touches <Icon id>, not other id= attributes", () => {
|
|
48
|
+
const f = join(dir, "src", "sections", "Nav.tsx");
|
|
49
|
+
writeFileSync(f, `<div id="close-button"><Icon id="close" /></div>`);
|
|
50
|
+
reconcileIconCasing(ctx);
|
|
51
|
+
const out = readFileSync(f, "utf8");
|
|
52
|
+
expect(out).toContain(`<div id="close-button">`); // untouched
|
|
53
|
+
expect(out).toContain(`<Icon id="Close" />`); // corrected
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("no-op when the site declares no AvailableIcons", () => {
|
|
57
|
+
rmSync(join(dir, "src", "components", "ui", "Icon.tsx"));
|
|
58
|
+
const f = join(dir, "src", "sections", "X.tsx");
|
|
59
|
+
writeFileSync(f, `<Icon id="close" />`);
|
|
60
|
+
reconcileIconCasing(ctx);
|
|
61
|
+
expect(readFileSync(f, "utf8")).toContain(`id="close"`); // unchanged
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { extractPlatform } from "./phase-analyze";
|
|
5
|
+
import { extractGoogleFonts, extractPlatform } from "./phase-analyze";
|
|
6
6
|
|
|
7
7
|
// `extractPlatform` runs four strategies in order:
|
|
8
8
|
// 1. deno.json imports referencing apps/{platform}/
|
|
@@ -10,12 +10,74 @@ import { extractPlatform } from "./phase-analyze";
|
|
|
10
10
|
// 3. apps/site.ts loose string match against platform name + "platform"
|
|
11
11
|
// 4. .deco/blocks filenames hinting at platform
|
|
12
12
|
// Strategy 3 is the false-positive trap that ate Magento sites before #211:
|
|
13
|
-
//
|
|
13
|
+
// a production site's apps/site.ts imports `apps/vtex/mod.ts` for the color
|
|
14
14
|
// palette, so the content matches `"vtex"` even though the real platform is
|
|
15
15
|
// declared via `apps/magento.ts`. The fix adds "magento" to the platforms
|
|
16
16
|
// list so Strategy 2 fires first and short-circuits before Strategy 3 can
|
|
17
17
|
// over-match.
|
|
18
18
|
|
|
19
|
+
describe("extractGoogleFonts", () => {
|
|
20
|
+
let tmp: string;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "extract-gfonts-"));
|
|
24
|
+
fs.mkdirSync(path.join(tmp, "routes"), { recursive: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("extracts preconnects and stylesheet from _app.tsx", () => {
|
|
32
|
+
fs.writeFileSync(
|
|
33
|
+
path.join(tmp, "routes", "_app.tsx"),
|
|
34
|
+
`<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
35
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
36
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" />`,
|
|
37
|
+
);
|
|
38
|
+
const result = extractGoogleFonts(tmp);
|
|
39
|
+
expect(result.preconnects).toEqual([
|
|
40
|
+
"https://fonts.googleapis.com",
|
|
41
|
+
"https://fonts.gstatic.com",
|
|
42
|
+
]);
|
|
43
|
+
expect(result.stylesheets).toEqual([
|
|
44
|
+
"https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap",
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("deduplicates URLs present in both _app.tsx and _layout.tsx", () => {
|
|
49
|
+
const sharedUrl = "https://fonts.googleapis.com/css2?family=Roboto&display=swap";
|
|
50
|
+
fs.writeFileSync(
|
|
51
|
+
path.join(tmp, "routes", "_app.tsx"),
|
|
52
|
+
`<link rel="stylesheet" href="${sharedUrl}" />`,
|
|
53
|
+
);
|
|
54
|
+
fs.writeFileSync(
|
|
55
|
+
path.join(tmp, "routes", "_layout.tsx"),
|
|
56
|
+
`<link rel="stylesheet" href="${sharedUrl}" />`,
|
|
57
|
+
);
|
|
58
|
+
const result = extractGoogleFonts(tmp);
|
|
59
|
+
expect(result.stylesheets).toHaveLength(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("extracts Google Fonts @import from tailwind.css", () => {
|
|
63
|
+
fs.writeFileSync(
|
|
64
|
+
path.join(tmp, "tailwind.css"),
|
|
65
|
+
`@import url('https://fonts.googleapis.com/css2?family=Poppins&display=swap');`,
|
|
66
|
+
);
|
|
67
|
+
const result = extractGoogleFonts(tmp);
|
|
68
|
+
expect(result.stylesheets).toEqual([
|
|
69
|
+
"https://fonts.googleapis.com/css2?family=Poppins&display=swap",
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns empty arrays when no Google Fonts are declared", () => {
|
|
74
|
+
fs.writeFileSync(path.join(tmp, "routes", "_app.tsx"), `<link rel="icon" href="/favicon.ico" />`);
|
|
75
|
+
const result = extractGoogleFonts(tmp);
|
|
76
|
+
expect(result.preconnects).toHaveLength(0);
|
|
77
|
+
expect(result.stylesheets).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
19
81
|
describe("extractPlatform", () => {
|
|
20
82
|
let tmp: string;
|
|
21
83
|
|
|
@@ -28,7 +90,7 @@ describe("extractPlatform", () => {
|
|
|
28
90
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
29
91
|
});
|
|
30
92
|
|
|
31
|
-
it("detects magento from apps/magento.ts (
|
|
93
|
+
it("detects magento from apps/magento.ts (a production site shape)", () => {
|
|
32
94
|
fs.writeFileSync(path.join(tmp, "apps", "magento.ts"), "export default {};\n");
|
|
33
95
|
// Also write a site.ts that imports vtex for color palettes — this is
|
|
34
96
|
// what tripped Strategy 3 before #211.
|
|
@@ -51,7 +113,7 @@ describe("extractPlatform", () => {
|
|
|
51
113
|
});
|
|
52
114
|
|
|
53
115
|
it("prefers magento over vtex when both an apps/magento.ts AND a vtex string match exist", () => {
|
|
54
|
-
// Reproduce the exact
|
|
116
|
+
// Reproduce the exact production trap: apps/magento.ts is the real signal,
|
|
55
117
|
// but apps/site.ts loosely contains "vtex" + "platform".
|
|
56
118
|
fs.writeFileSync(path.join(tmp, "apps", "magento.ts"), "export default {};\n");
|
|
57
119
|
fs.writeFileSync(
|