@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.
- package/package.json +44 -0
- package/scripts/analyze-traces.mjs +1117 -0
- package/scripts/audit-observability-config.test.ts +446 -0
- package/scripts/audit-observability-config.ts +511 -0
- package/scripts/deco-migrate-cli.ts +444 -0
- package/scripts/fast-deploy-kv.test.ts +131 -0
- package/scripts/generate-blocks.test.ts +94 -0
- package/scripts/generate-blocks.ts +274 -0
- package/scripts/generate-invoke.test.ts +195 -0
- package/scripts/generate-invoke.ts +469 -0
- package/scripts/generate-loaders.ts +217 -0
- package/scripts/generate-schema.ts +1287 -0
- package/scripts/generate-sections.ts +237 -0
- package/scripts/htmx-analyze.ts +226 -0
- package/scripts/lib/blocks-dedupe.test.ts +179 -0
- package/scripts/lib/blocks-dedupe.ts +142 -0
- package/scripts/lib/cf-kv-rest.ts +78 -0
- package/scripts/lib/jsonc.ts +122 -0
- package/scripts/lib/kv-snapshot.ts +51 -0
- package/scripts/lib/read-decofile.ts +70 -0
- package/scripts/lib/sync-helpers.ts +44 -0
- package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
- package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
- package/scripts/migrate/analyzers/island-classifier.ts +96 -0
- package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
- package/scripts/migrate/analyzers/section-metadata.ts +147 -0
- package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
- package/scripts/migrate/colors.ts +46 -0
- package/scripts/migrate/config.test.ts +202 -0
- package/scripts/migrate/config.ts +186 -0
- package/scripts/migrate/phase-analyze.test.ts +63 -0
- package/scripts/migrate/phase-analyze.ts +782 -0
- package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
- package/scripts/migrate/phase-cleanup-audit.ts +105 -0
- package/scripts/migrate/phase-cleanup.test.ts +141 -0
- package/scripts/migrate/phase-cleanup.ts +1588 -0
- package/scripts/migrate/phase-compile.test.ts +193 -0
- package/scripts/migrate/phase-compile.ts +177 -0
- package/scripts/migrate/phase-report.ts +243 -0
- package/scripts/migrate/phase-scaffold.ts +593 -0
- package/scripts/migrate/phase-transform.ts +310 -0
- package/scripts/migrate/phase-verify.test.ts +127 -0
- package/scripts/migrate/phase-verify.ts +572 -0
- package/scripts/migrate/post-cleanup/rules.ts +1708 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
- package/scripts/migrate/post-cleanup/runner.ts +137 -0
- package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
- package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
- package/scripts/migrate/post-cleanup/types.ts +106 -0
- package/scripts/migrate/source-layout.test.ts +111 -0
- package/scripts/migrate/source-layout.ts +103 -0
- package/scripts/migrate/templates/app-css.ts +366 -0
- package/scripts/migrate/templates/cache-config.ts +26 -0
- package/scripts/migrate/templates/commerce-loaders.ts +230 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
- package/scripts/migrate/templates/cursor-rules.ts +70 -0
- package/scripts/migrate/templates/hooks.test.ts +141 -0
- package/scripts/migrate/templates/hooks.ts +152 -0
- package/scripts/migrate/templates/knip-config.ts +27 -0
- package/scripts/migrate/templates/lib-utils.test.ts +139 -0
- package/scripts/migrate/templates/lib-utils.ts +326 -0
- package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
- package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
- package/scripts/migrate/templates/package-json.ts +177 -0
- package/scripts/migrate/templates/routes.ts +237 -0
- package/scripts/migrate/templates/sdk-gen.ts +59 -0
- package/scripts/migrate/templates/section-loaders.ts +456 -0
- package/scripts/migrate/templates/server-entry.ts +561 -0
- package/scripts/migrate/templates/setup.ts +148 -0
- package/scripts/migrate/templates/tsconfig.ts +21 -0
- package/scripts/migrate/templates/types-gen.ts +174 -0
- package/scripts/migrate/templates/ui-components.ts +144 -0
- package/scripts/migrate/templates/vite-config.ts +101 -0
- package/scripts/migrate/transforms/dead-code.ts +455 -0
- package/scripts/migrate/transforms/deno-isms.ts +85 -0
- package/scripts/migrate/transforms/fresh-apis.ts +223 -0
- package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
- package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
- package/scripts/migrate/transforms/imports.ts +385 -0
- package/scripts/migrate/transforms/jsx.ts +317 -0
- package/scripts/migrate/transforms/section-conventions.ts +210 -0
- package/scripts/migrate/transforms/tailwind.ts +739 -0
- package/scripts/migrate/types.ts +244 -0
- package/scripts/migrate-blocks-to-kv.ts +104 -0
- package/scripts/migrate-post-cleanup.ts +191 -0
- package/scripts/migrate-to-cf-observability.test.ts +215 -0
- package/scripts/migrate-to-cf-observability.ts +699 -0
- package/scripts/migrate.ts +282 -0
- package/scripts/smoke-otlp-errorlog.ts +46 -0
- package/scripts/smoke-otlp-meter.ts +48 -0
- package/scripts/sync-blocks-to-kv.ts +153 -0
- package/scripts/tailwind-lint.ts +518 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { MigrationContext } from "./types";
|
|
4
|
+
import { log, logPhase } from "./types";
|
|
5
|
+
import { CANONICAL_BUN_VERSION, generatePackageJson } from "./templates/package-json";
|
|
6
|
+
import { generateLockfileCheckYml } from "./templates/lockfile-check-yml";
|
|
7
|
+
import { generateTsconfig } from "./templates/tsconfig";
|
|
8
|
+
import { generateViteConfig } from "./templates/vite-config";
|
|
9
|
+
import { generateKnipConfig } from "./templates/knip-config";
|
|
10
|
+
import { generateRoutes } from "./templates/routes";
|
|
11
|
+
import { generateSetup } from "./templates/setup";
|
|
12
|
+
import { generateServerEntry } from "./templates/server-entry";
|
|
13
|
+
import { generateAppCss } from "./templates/app-css";
|
|
14
|
+
import { generateTypeFiles } from "./templates/types-gen";
|
|
15
|
+
import { generateUiComponents } from "./templates/ui-components";
|
|
16
|
+
import { generateHooks } from "./templates/hooks";
|
|
17
|
+
import { generateCommerceLoaders } from "./templates/commerce-loaders";
|
|
18
|
+
import { generateSectionLoaders } from "./templates/section-loaders";
|
|
19
|
+
import { generateCacheConfig } from "./templates/cache-config";
|
|
20
|
+
import { generateSdkFiles } from "./templates/sdk-gen";
|
|
21
|
+
import { generateMigrationPolicyPointerRule } from "./templates/cursor-rules";
|
|
22
|
+
// `lib-utils` is imported lazily — see end of phase-cleanup. Eager
|
|
23
|
+
// generation of all 11 shims left every site with dead code that had
|
|
24
|
+
// to be cleaned up by hand.
|
|
25
|
+
import { extractTheme } from "./analyzers/theme-extractor";
|
|
26
|
+
|
|
27
|
+
function writeFile(ctx: MigrationContext, relPath: string, content: string) {
|
|
28
|
+
const fullPath = path.join(ctx.sourceDir, relPath);
|
|
29
|
+
|
|
30
|
+
if (ctx.dryRun) {
|
|
31
|
+
log(ctx, `[DRY] Would create: ${relPath}`);
|
|
32
|
+
ctx.scaffoldedFiles.push(relPath);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const dir = path.dirname(fullPath);
|
|
37
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
38
|
+
|
|
39
|
+
fs.writeFileSync(fullPath, content, "utf-8");
|
|
40
|
+
log(ctx, `Created: ${relPath}`);
|
|
41
|
+
ctx.scaffoldedFiles.push(relPath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeMultiFile(ctx: MigrationContext, files: Record<string, string>) {
|
|
45
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
46
|
+
writeFile(ctx, filePath, content);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function scaffold(ctx: MigrationContext): void {
|
|
51
|
+
logPhase("Scaffold");
|
|
52
|
+
|
|
53
|
+
// Root config files. wrangler.jsonc is intentionally NOT generated by
|
|
54
|
+
// the migration script -- it's per-site, lives in the site repo, and
|
|
55
|
+
// is wired by hand on first deploy (D6.3 interim state until the
|
|
56
|
+
// Cloudflare Workers Builds onboarding lands).
|
|
57
|
+
writeFile(ctx, "package.json", generatePackageJson(ctx));
|
|
58
|
+
writeFile(ctx, "tsconfig.json", generateTsconfig());
|
|
59
|
+
writeFile(ctx, "vite.config.ts", generateViteConfig(ctx));
|
|
60
|
+
writeFile(ctx, "knip.config.ts", generateKnipConfig());
|
|
61
|
+
writeFile(ctx, ".gitignore", generateGitignore());
|
|
62
|
+
|
|
63
|
+
// Deploy / preview pipelines are owned by Cloudflare Workers Builds
|
|
64
|
+
// configured per-worker in the CF dashboard (D6.3). The migration
|
|
65
|
+
// does NOT scaffold deploy/preview/sync-secrets workflows in the site
|
|
66
|
+
// repo; the operator wires the repo<->worker connection in the CF
|
|
67
|
+
// dashboard once after the first push.
|
|
68
|
+
writeFile(ctx, ".prettierrc", JSON.stringify({
|
|
69
|
+
semi: true,
|
|
70
|
+
singleQuote: false,
|
|
71
|
+
trailingComma: "all" as const,
|
|
72
|
+
printWidth: 100,
|
|
73
|
+
tabWidth: 2,
|
|
74
|
+
}, null, 2) + "\n");
|
|
75
|
+
|
|
76
|
+
// PR-time lockfile guardrail. Lives in the site repo (per-site, not
|
|
77
|
+
// a centralised reusable workflow) because per D6.3 we are not
|
|
78
|
+
// scaffolding caller stubs into storefronts. Bun version is pinned
|
|
79
|
+
// in lockstep with `package.json` via CANONICAL_BUN_VERSION.
|
|
80
|
+
writeFile(
|
|
81
|
+
ctx,
|
|
82
|
+
".github/workflows/lockfile-check.yml",
|
|
83
|
+
generateLockfileCheckYml(CANONICAL_BUN_VERSION),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Server entry files (server.ts, worker-entry.ts, router.tsx, runtime.ts, context.ts)
|
|
87
|
+
writeMultiFile(ctx, generateServerEntry(ctx));
|
|
88
|
+
|
|
89
|
+
// Route files
|
|
90
|
+
writeMultiFile(ctx, generateRoutes(ctx));
|
|
91
|
+
|
|
92
|
+
// Secrets — extract env vars referenced by source AppContext
|
|
93
|
+
// Must be generated BEFORE commerce-loaders and section-loaders since
|
|
94
|
+
// those templates check for the secrets file to wire `...secrets` spreads.
|
|
95
|
+
writeFile(ctx, "src/utils/secrets.ts", generateSecrets(ctx));
|
|
96
|
+
|
|
97
|
+
// Apps
|
|
98
|
+
writeFile(ctx, "src/apps/site.ts", generateSiteApp(ctx));
|
|
99
|
+
|
|
100
|
+
// account.json is copied from source (if exists) or generated as fallback
|
|
101
|
+
if (!ctx.files.some((f) => f.path === "account.json" && f.action !== "delete")) {
|
|
102
|
+
const accountName = ctx.vtexAccount || ctx.siteName;
|
|
103
|
+
writeFile(ctx, "src/account.json", JSON.stringify(accountName));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Setup infrastructure
|
|
107
|
+
writeFile(ctx, "src/setup.ts", generateSetup(ctx));
|
|
108
|
+
writeFile(ctx, "src/cache-config.ts", generateCacheConfig(ctx));
|
|
109
|
+
writeFile(ctx, "src/setup/commerce-loaders.ts", generateCommerceLoaders(ctx));
|
|
110
|
+
writeFile(ctx, "src/setup/section-loaders.ts", generateSectionLoaders(ctx));
|
|
111
|
+
|
|
112
|
+
// Theme extraction + Styles
|
|
113
|
+
const theme = extractTheme(ctx);
|
|
114
|
+
writeFile(ctx, "src/styles/app.css", generateAppCss(ctx, theme));
|
|
115
|
+
|
|
116
|
+
// Type definitions
|
|
117
|
+
writeMultiFile(ctx, generateTypeFiles(ctx));
|
|
118
|
+
|
|
119
|
+
// UI components (Image, Picture, Video)
|
|
120
|
+
writeMultiFile(ctx, generateUiComponents(ctx));
|
|
121
|
+
|
|
122
|
+
// Platform hooks (useCart, useUser, useWishlist)
|
|
123
|
+
writeMultiFile(ctx, generateHooks(ctx));
|
|
124
|
+
|
|
125
|
+
// SDK shims + generated utilities
|
|
126
|
+
writeFile(ctx, "src/sdk/signal.ts", generateSignalShim());
|
|
127
|
+
writeFile(ctx, "src/sdk/clx.ts", generateClxShim());
|
|
128
|
+
writeFile(ctx, "src/sdk/debounce.ts", generateDebounceShim());
|
|
129
|
+
writeFile(ctx, "src/sdk/logger.ts", generateLoggerStub());
|
|
130
|
+
writeMultiFile(ctx, generateSdkFiles(ctx));
|
|
131
|
+
|
|
132
|
+
// VTEX utility wrappers (signature-compatible stubs) are no longer
|
|
133
|
+
// generated eagerly here. They're written lazily at end of phase-cleanup,
|
|
134
|
+
// after all import rewrites have run, so that we only emit shims that
|
|
135
|
+
// some file actually imports. See `writeImportedLibShims` in phase-cleanup.
|
|
136
|
+
|
|
137
|
+
// Replace Context-based useDevice with SSR-safe useSyncExternalStore version.
|
|
138
|
+
// @decocms/start shell-renders sections in a separate React root without
|
|
139
|
+
// Device.Provider, so the old createContext pattern throws during SSR.
|
|
140
|
+
writeFile(ctx, "src/contexts/device.tsx", generateDeviceContext());
|
|
141
|
+
|
|
142
|
+
// Location matcher — server-side geolocation matching
|
|
143
|
+
if (hasLocationMatcher(ctx)) {
|
|
144
|
+
writeFile(ctx, "src/matchers/location.ts", generateLocationMatcher());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// SiteTheme component (replaces apps/website/components/Theme.tsx)
|
|
148
|
+
const usesSiteTheme = ctx.files.some((f) => {
|
|
149
|
+
if (f.action === "delete") return false;
|
|
150
|
+
try {
|
|
151
|
+
const content = fs.readFileSync(f.absPath, "utf-8");
|
|
152
|
+
return content.includes("SiteTheme");
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
if (usesSiteTheme) {
|
|
158
|
+
writeFile(ctx, "src/components/ui/Theme.tsx", generateSiteThemeComponent());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Migration tooling policy pointer rule (D1–D5 + priorities).
|
|
162
|
+
// The canonical rule lives in decocms/deco-start; this is a tiny
|
|
163
|
+
// pointer that loads on every Cursor session in the migrated site
|
|
164
|
+
// so agents working on the site know where the policy is and what
|
|
165
|
+
// it means here. See MIGRATION_TOOLING_PLAN.md (Wave 12-H).
|
|
166
|
+
writeFile(
|
|
167
|
+
ctx,
|
|
168
|
+
".cursor/rules/migration-tooling-policy.mdc",
|
|
169
|
+
generateMigrationPolicyPointerRule(ctx.siteName),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Create public/ directory
|
|
173
|
+
if (!ctx.dryRun) {
|
|
174
|
+
fs.mkdirSync(path.join(ctx.sourceDir, "public"), { recursive: true });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log(` Scaffolded ${ctx.scaffoldedFiles.length} files`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function generateSiteThemeComponent(): string {
|
|
181
|
+
return `export interface Font {
|
|
182
|
+
family: string;
|
|
183
|
+
styleSheet?: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface Props {
|
|
187
|
+
colorScheme?: "light" | "dark" | "any";
|
|
188
|
+
fonts?: Font[];
|
|
189
|
+
variables?: Array<{ name: string; value: string }>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export default function SiteTheme({ variables, fonts, colorScheme }: Props) {
|
|
193
|
+
const cssVars = variables?.length
|
|
194
|
+
? \`:root { \${variables.map((v) => \`\${v.name}: \${v.value};\`).join(" ")} }\`
|
|
195
|
+
: "";
|
|
196
|
+
|
|
197
|
+
const colorSchemeCss = colorScheme && colorScheme !== "any"
|
|
198
|
+
? \`:root { color-scheme: \${colorScheme}; }\`
|
|
199
|
+
: "";
|
|
200
|
+
|
|
201
|
+
const css = [cssVars, colorSchemeCss].filter(Boolean).join("\\n");
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<>
|
|
205
|
+
{fonts?.map((font) =>
|
|
206
|
+
font.styleSheet ? (
|
|
207
|
+
<link key={font.family} rel="stylesheet" href={font.styleSheet} />
|
|
208
|
+
) : null
|
|
209
|
+
)}
|
|
210
|
+
{css && <style dangerouslySetInnerHTML={{ __html: css }} />}
|
|
211
|
+
</>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export { type Font as SiteThemeFont };
|
|
216
|
+
`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function generateGitignore(): string {
|
|
220
|
+
return `# Dependencies
|
|
221
|
+
node_modules/
|
|
222
|
+
|
|
223
|
+
# Build output
|
|
224
|
+
dist/
|
|
225
|
+
.cache/
|
|
226
|
+
|
|
227
|
+
# Cloudflare Workers
|
|
228
|
+
.wrangler/
|
|
229
|
+
.dev.vars
|
|
230
|
+
|
|
231
|
+
# TanStack Router (auto-generated)
|
|
232
|
+
src/routeTree.gen.ts
|
|
233
|
+
.tanstack/
|
|
234
|
+
|
|
235
|
+
# Vite
|
|
236
|
+
vite.config.timestamp_*
|
|
237
|
+
*.local
|
|
238
|
+
|
|
239
|
+
# Environment
|
|
240
|
+
.env
|
|
241
|
+
.env.*
|
|
242
|
+
|
|
243
|
+
# OS
|
|
244
|
+
.DS_Store
|
|
245
|
+
|
|
246
|
+
# Deco CMS
|
|
247
|
+
.deco/metadata/*
|
|
248
|
+
|
|
249
|
+
# IDE
|
|
250
|
+
.vscode/
|
|
251
|
+
.idea/
|
|
252
|
+
|
|
253
|
+
# Lockfiles — bun is canonical, prevent accidental drift
|
|
254
|
+
package-lock.json
|
|
255
|
+
yarn.lock
|
|
256
|
+
pnpm-lock.yaml
|
|
257
|
+
`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function generateClxShim(): string {
|
|
261
|
+
return `/** Filter out nullable values, join and minify class names */
|
|
262
|
+
export const clx = (...args: (string | null | undefined | false)[]) =>
|
|
263
|
+
args.filter(Boolean).join(" ").replace(/\\s\\s+/g, " ");
|
|
264
|
+
|
|
265
|
+
/** Alias for compat — some files import as clsx */
|
|
266
|
+
export const clsx = clx;
|
|
267
|
+
|
|
268
|
+
export default clx;
|
|
269
|
+
`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function generateLoggerStub(): string {
|
|
273
|
+
return `export const logger = {
|
|
274
|
+
error: console.error,
|
|
275
|
+
warn: console.warn,
|
|
276
|
+
info: console.info,
|
|
277
|
+
debug: console.debug,
|
|
278
|
+
log: console.log,
|
|
279
|
+
};
|
|
280
|
+
`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function generateDebounceShim(): string {
|
|
284
|
+
return `/** Debounce a function call — drop-in replacement for Deno std/async/debounce */
|
|
285
|
+
export function debounce<T extends (...args: any[]) => any>(
|
|
286
|
+
fn: T,
|
|
287
|
+
delay = 250,
|
|
288
|
+
): T & { clear(): void } {
|
|
289
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
290
|
+
|
|
291
|
+
const debounced = ((...args: Parameters<T>) => {
|
|
292
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
293
|
+
timer = setTimeout(() => {
|
|
294
|
+
timer = undefined;
|
|
295
|
+
fn(...args);
|
|
296
|
+
}, delay);
|
|
297
|
+
}) as T & { clear(): void };
|
|
298
|
+
|
|
299
|
+
debounced.clear = () => {
|
|
300
|
+
if (timer !== undefined) {
|
|
301
|
+
clearTimeout(timer);
|
|
302
|
+
timer = undefined;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
return debounced;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export default debounce;
|
|
310
|
+
`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function generateSignalShim(): string {
|
|
314
|
+
return `import { useState, useRef, useEffect, useMemo, useCallback } from "react";
|
|
315
|
+
export { signal, type ReactiveSignal } from "@decocms/start/sdk/signal";
|
|
316
|
+
|
|
317
|
+
/** Run a function immediately. Kept for legacy module-level side effects. */
|
|
318
|
+
export function effect(fn: () => void | (() => void)): () => void {
|
|
319
|
+
const cleanup = fn();
|
|
320
|
+
return typeof cleanup === "function" ? cleanup : () => {};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* React shim for @preact/signals' useSignal.
|
|
325
|
+
* Returns a mutable ref-like object with a .value property that triggers re-renders.
|
|
326
|
+
*/
|
|
327
|
+
export function useSignal<T>(initialValue: T): { value: T } {
|
|
328
|
+
const [value, setValue] = useState<T>(initialValue);
|
|
329
|
+
const ref = useRef(value);
|
|
330
|
+
ref.current = value;
|
|
331
|
+
return useMemo(
|
|
332
|
+
() => ({
|
|
333
|
+
get value() { return ref.current; },
|
|
334
|
+
set value(v: T) {
|
|
335
|
+
ref.current = v;
|
|
336
|
+
setValue(v);
|
|
337
|
+
},
|
|
338
|
+
}),
|
|
339
|
+
[],
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* React shim for @preact/signals' useComputed.
|
|
345
|
+
* Re-evaluates when deps change (but since we don't track signals, it runs every render).
|
|
346
|
+
*/
|
|
347
|
+
export function useComputed<T>(compute: () => T): { readonly value: T } {
|
|
348
|
+
const [value, setValue] = useState<T>(compute);
|
|
349
|
+
useEffect(() => { setValue(compute()); });
|
|
350
|
+
return useMemo(() => ({ get value() { return value; } }), [value]);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* React shim for @preact/signals' useSignalEffect.
|
|
355
|
+
* Runs the callback as a useEffect (no automatic signal tracking).
|
|
356
|
+
*/
|
|
357
|
+
export function useSignalEffect(cb: () => void | (() => void)): void {
|
|
358
|
+
useEffect(cb);
|
|
359
|
+
}
|
|
360
|
+
`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function generateDeviceContext(): string {
|
|
364
|
+
return `import { useSyncExternalStore } from "react";
|
|
365
|
+
|
|
366
|
+
const MOBILE_QUERY = "(max-width: 767px)";
|
|
367
|
+
|
|
368
|
+
function subscribe(cb: () => void) {
|
|
369
|
+
if (typeof window === "undefined") return () => {};
|
|
370
|
+
const mql = window.matchMedia(MOBILE_QUERY);
|
|
371
|
+
mql.addEventListener("change", cb);
|
|
372
|
+
return () => mql.removeEventListener("change", cb);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function getSnapshot(): boolean {
|
|
376
|
+
return window.matchMedia(MOBILE_QUERY).matches;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function getServerSnapshot(): boolean {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Reactive mobile detection based on viewport width via matchMedia.
|
|
385
|
+
* SSR defaults to desktop (false); hydrates to the real value on mount.
|
|
386
|
+
*
|
|
387
|
+
* For server-side device detection (UA-based), use the section loader
|
|
388
|
+
* pattern: registerSectionLoaders injects \`isMobile\` as a prop.
|
|
389
|
+
*/
|
|
390
|
+
export const useDevice = () => {
|
|
391
|
+
const isMobile = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
392
|
+
return { isMobile };
|
|
393
|
+
};
|
|
394
|
+
`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function generateSiteApp(ctx: MigrationContext): string {
|
|
398
|
+
// Try to read source site.ts to extract secrets and AppContext shape
|
|
399
|
+
const secretFields = extractSecretFields(ctx);
|
|
400
|
+
|
|
401
|
+
let secretImport = "";
|
|
402
|
+
let secretTypes = "";
|
|
403
|
+
if (secretFields.length > 0) {
|
|
404
|
+
secretImport = `\nimport type { Secret } from "~/utils/secrets";\n`;
|
|
405
|
+
secretTypes = secretFields.map((f) => ` ${f}: Secret;`).join("\n");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const vtexAccount = ctx.vtexAccount || ctx.siteName;
|
|
409
|
+
|
|
410
|
+
return `export type Platform =
|
|
411
|
+
| "vtex"
|
|
412
|
+
| "vnda"
|
|
413
|
+
| "shopify"
|
|
414
|
+
| "wake"
|
|
415
|
+
| "linx"
|
|
416
|
+
| "nuvemshop"
|
|
417
|
+
| "custom";
|
|
418
|
+
|
|
419
|
+
export const _platform: Platform = "${ctx.platform}";
|
|
420
|
+
${secretImport}
|
|
421
|
+
export type AppContext = {
|
|
422
|
+
device: "mobile" | "desktop" | "tablet";
|
|
423
|
+
platform: Platform;${secretTypes ? `\n${secretTypes}` : ""}${ctx.platform === "vtex" ? `\n account: string;` : ""}
|
|
424
|
+
};
|
|
425
|
+
`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function extractSecretFields(ctx: MigrationContext): string[] {
|
|
429
|
+
const siteAppPaths = [
|
|
430
|
+
path.join(ctx.sourceDir, "apps", "site.ts"),
|
|
431
|
+
path.join(ctx.sourceDir, "src", "apps", "site.ts"),
|
|
432
|
+
];
|
|
433
|
+
|
|
434
|
+
for (const p of siteAppPaths) {
|
|
435
|
+
if (!fs.existsSync(p)) continue;
|
|
436
|
+
const content = fs.readFileSync(p, "utf-8");
|
|
437
|
+
const secretRe = /(\w+):\s*Secret\b/g;
|
|
438
|
+
const fields: string[] = [];
|
|
439
|
+
let match;
|
|
440
|
+
while ((match = secretRe.exec(content)) !== null) {
|
|
441
|
+
fields.push(match[1]);
|
|
442
|
+
}
|
|
443
|
+
return fields;
|
|
444
|
+
}
|
|
445
|
+
return [];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function generateSecrets(ctx: MigrationContext): string {
|
|
449
|
+
const fields = extractSecretFields(ctx);
|
|
450
|
+
|
|
451
|
+
if (fields.length === 0) {
|
|
452
|
+
return `export interface Secret {
|
|
453
|
+
get(): string;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function envSecret(envKey: string): Secret {
|
|
457
|
+
return {
|
|
458
|
+
get: () => (process.env[envKey] as string) ?? "",
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export const secrets = {} as const;
|
|
463
|
+
`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const envKeyMap: Record<string, string> = {
|
|
467
|
+
GatewayApiKey: "GATEWAY_API_KEY",
|
|
468
|
+
topsortkey: "TOPSORT_KEY",
|
|
469
|
+
yourviewsToken: "YOURVIEWS_TOKEN",
|
|
470
|
+
pickuppointsAppKey: "PICKUPPOINTS_APP_KEY",
|
|
471
|
+
pickuppointsAppToken: "PICKUPPOINTS_APP_TOKEN",
|
|
472
|
+
SAPUser: "SAP_USER",
|
|
473
|
+
SAPPassword: "SAP_PASSWORD",
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const entries = fields.map((f) => {
|
|
477
|
+
const envKey = envKeyMap[f] || f.replace(/([A-Z])/g, "_$1").toUpperCase();
|
|
478
|
+
return ` ${f}: envSecret("${envKey}"),`;
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
return `export interface Secret {
|
|
482
|
+
get(): string;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function envSecret(envKey: string): Secret {
|
|
486
|
+
return {
|
|
487
|
+
get: () => (process.env[envKey] as string) ?? "",
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* All site-level secrets, sourced from Cloudflare Worker env bindings
|
|
493
|
+
* (process.env is polyfilled by nodejs_compat).
|
|
494
|
+
*
|
|
495
|
+
* Local dev: .dev.vars
|
|
496
|
+
* Production: \`wrangler secret put <KEY>\`
|
|
497
|
+
*/
|
|
498
|
+
export const secrets = {
|
|
499
|
+
${entries.join("\n")}
|
|
500
|
+
} as const;
|
|
501
|
+
`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function hasLocationMatcher(ctx: MigrationContext): boolean {
|
|
505
|
+
const dirs = [
|
|
506
|
+
path.join(ctx.sourceDir, "matchers"),
|
|
507
|
+
path.join(ctx.sourceDir, "src", "matchers"),
|
|
508
|
+
];
|
|
509
|
+
for (const dir of dirs) {
|
|
510
|
+
if (fs.existsSync(dir)) {
|
|
511
|
+
const files = fs.readdirSync(dir);
|
|
512
|
+
if (files.some((f) => f.includes("location"))) return true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// Also check .deco/blocks for location matcher references
|
|
516
|
+
const blocksDir = path.join(ctx.sourceDir, ".deco", "blocks");
|
|
517
|
+
if (fs.existsSync(blocksDir)) {
|
|
518
|
+
for (const file of fs.readdirSync(blocksDir)) {
|
|
519
|
+
if (!file.endsWith(".json")) continue;
|
|
520
|
+
try {
|
|
521
|
+
const content = fs.readFileSync(path.join(blocksDir, file), "utf-8");
|
|
522
|
+
if (content.includes("website/matchers/location")) return true;
|
|
523
|
+
} catch { /* skip */ }
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function generateLocationMatcher(): string {
|
|
530
|
+
return `/**
|
|
531
|
+
* Server-side location matcher for website/matchers/location.ts
|
|
532
|
+
*
|
|
533
|
+
* Reads CF geolocation data injected as internal cookies by worker-entry.ts
|
|
534
|
+
* (__cf_geo_region, __cf_geo_country, __cf_geo_city) to evaluate location
|
|
535
|
+
* rules server-side.
|
|
536
|
+
*/
|
|
537
|
+
|
|
538
|
+
import { registerMatcher } from "@decocms/start/cms";
|
|
539
|
+
import type { MatcherContext } from "@decocms/start/cms";
|
|
540
|
+
|
|
541
|
+
const COUNTRY_NAME_TO_CODE: Record<string, string> = {
|
|
542
|
+
Brasil: "BR",
|
|
543
|
+
Brazil: "BR",
|
|
544
|
+
Argentina: "AR",
|
|
545
|
+
Chile: "CL",
|
|
546
|
+
Colombia: "CO",
|
|
547
|
+
Uruguay: "UY",
|
|
548
|
+
Paraguay: "PY",
|
|
549
|
+
Peru: "PE",
|
|
550
|
+
"United States": "US",
|
|
551
|
+
Portugal: "PT",
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
interface LocationRule {
|
|
555
|
+
regionCode?: string;
|
|
556
|
+
country?: string;
|
|
557
|
+
city?: string;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function matchesRule(loc: LocationRule, region: string, country: string, city: string): boolean {
|
|
561
|
+
if (loc.country) {
|
|
562
|
+
const code = COUNTRY_NAME_TO_CODE[loc.country] ?? loc.country;
|
|
563
|
+
if (code !== country) return false;
|
|
564
|
+
}
|
|
565
|
+
if (loc.regionCode && loc.regionCode !== region) return false;
|
|
566
|
+
if (loc.city && loc.city.toLowerCase() !== city.toLowerCase()) return false;
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export function registerLocationMatcher(): void {
|
|
571
|
+
registerMatcher(
|
|
572
|
+
"website/matchers/location.ts",
|
|
573
|
+
(rule: Record<string, unknown>, ctx: MatcherContext): boolean => {
|
|
574
|
+
const includeLocations = (rule.includeLocations as LocationRule[] | undefined) ?? [];
|
|
575
|
+
const excludeLocations = (rule.excludeLocations as LocationRule[] | undefined) ?? [];
|
|
576
|
+
|
|
577
|
+
const cookies = ctx.cookies ?? {};
|
|
578
|
+
const region = cookies.__cf_geo_region ? decodeURIComponent(cookies.__cf_geo_region) : "";
|
|
579
|
+
const country = cookies.__cf_geo_country ? decodeURIComponent(cookies.__cf_geo_country) : "";
|
|
580
|
+
const city = cookies.__cf_geo_city ? decodeURIComponent(cookies.__cf_geo_city) : "";
|
|
581
|
+
|
|
582
|
+
if (excludeLocations.some((loc) => matchesRule(loc, region, country, city))) {
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (includeLocations.length === 0) return true;
|
|
587
|
+
|
|
588
|
+
return includeLocations.some((loc) => matchesRule(loc, region, country, city));
|
|
589
|
+
},
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
`;
|
|
593
|
+
}
|