@decocms/start 1.6.2 → 1.7.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/.cursor/skills/deco-to-tanstack-migration/SKILL.md +85 -12
- package/.cursor/skills/deco-to-tanstack-migration/references/gotchas.md +98 -0
- package/.cursor/skills/deco-to-tanstack-migration/templates/package-json.md +45 -25
- package/.cursor/skills/deco-to-tanstack-migration/templates/root-route.md +56 -39
- package/.cursor/skills/deco-to-tanstack-migration/templates/setup-ts.md +122 -141
- package/.releaserc.json +1 -0
- package/package.json +1 -1
- package/scripts/generate-blocks.ts +8 -5
- package/scripts/generate-loaders.ts +79 -12
- package/scripts/migrate/analyzers/island-classifier.ts +23 -0
- package/scripts/migrate/analyzers/section-metadata.ts +63 -7
- package/scripts/migrate/phase-analyze.ts +190 -11
- package/scripts/migrate/phase-cleanup.ts +1162 -7
- package/scripts/migrate/phase-scaffold.ts +294 -5
- package/scripts/migrate/phase-transform.ts +56 -3
- package/scripts/migrate/templates/app-css.ts +149 -2
- package/scripts/migrate/templates/commerce-loaders.ts +174 -69
- package/scripts/migrate/templates/lib-utils.ts +255 -0
- package/scripts/migrate/templates/package-json.ts +30 -22
- package/scripts/migrate/templates/routes.ts +81 -11
- package/scripts/migrate/templates/section-loaders.ts +369 -33
- package/scripts/migrate/templates/server-entry.ts +350 -80
- package/scripts/migrate/templates/setup.ts +78 -8
- package/scripts/migrate/templates/types-gen.ts +58 -0
- package/scripts/migrate/templates/ui-components.ts +47 -16
- package/scripts/migrate/templates/vite-config.ts +17 -6
- package/scripts/migrate/templates/wrangler.ts +3 -1
- package/scripts/migrate/transforms/dead-code.ts +330 -4
- package/scripts/migrate/transforms/deno-isms.ts +19 -0
- package/scripts/migrate/transforms/imports.ts +93 -30
- package/scripts/migrate/transforms/jsx.ts +79 -4
- package/scripts/migrate/transforms/section-conventions.ts +105 -3
- package/scripts/migrate/types.ts +9 -1
- package/src/cms/resolve.ts +12 -1
- package/src/sdk/useScript.ts +27 -6
- package/src/sdk/workerEntry.ts +11 -2
- package/src/setup.ts +1 -1
|
@@ -18,6 +18,7 @@ import { generateCommerceLoaders } from "./templates/commerce-loaders.ts";
|
|
|
18
18
|
import { generateSectionLoaders } from "./templates/section-loaders.ts";
|
|
19
19
|
import { generateCacheConfig } from "./templates/cache-config.ts";
|
|
20
20
|
import { generateSdkFiles } from "./templates/sdk-gen.ts";
|
|
21
|
+
import { generateLibUtils } from "./templates/lib-utils.ts";
|
|
21
22
|
import { extractTheme } from "./analyzers/theme-extractor.ts";
|
|
22
23
|
|
|
23
24
|
function writeFile(ctx: MigrationContext, relPath: string, content: string) {
|
|
@@ -67,6 +68,20 @@ export function scaffold(ctx: MigrationContext): void {
|
|
|
67
68
|
// Route files
|
|
68
69
|
writeMultiFile(ctx, generateRoutes(ctx));
|
|
69
70
|
|
|
71
|
+
// Secrets — extract env vars referenced by source AppContext
|
|
72
|
+
// Must be generated BEFORE commerce-loaders and section-loaders since
|
|
73
|
+
// those templates check for the secrets file to wire `...secrets` spreads.
|
|
74
|
+
writeFile(ctx, "src/utils/secrets.ts", generateSecrets(ctx));
|
|
75
|
+
|
|
76
|
+
// Apps
|
|
77
|
+
writeFile(ctx, "src/apps/site.ts", generateSiteApp(ctx));
|
|
78
|
+
|
|
79
|
+
// account.json is copied from source (if exists) or generated as fallback
|
|
80
|
+
if (!ctx.files.some((f) => f.path === "account.json" && f.action !== "delete")) {
|
|
81
|
+
const accountName = ctx.vtexAccount || ctx.siteName;
|
|
82
|
+
writeFile(ctx, "src/account.json", JSON.stringify(accountName));
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
// Setup infrastructure
|
|
71
86
|
writeFile(ctx, "src/setup.ts", generateSetup(ctx));
|
|
72
87
|
writeFile(ctx, "src/cache-config.ts", generateCacheConfig(ctx));
|
|
@@ -90,10 +105,21 @@ export function scaffold(ctx: MigrationContext): void {
|
|
|
90
105
|
writeFile(ctx, "src/sdk/signal.ts", generateSignalShim());
|
|
91
106
|
writeFile(ctx, "src/sdk/clx.ts", generateClxShim());
|
|
92
107
|
writeFile(ctx, "src/sdk/debounce.ts", generateDebounceShim());
|
|
108
|
+
writeFile(ctx, "src/sdk/logger.ts", generateLoggerStub());
|
|
93
109
|
writeMultiFile(ctx, generateSdkFiles(ctx));
|
|
94
110
|
|
|
95
|
-
//
|
|
96
|
-
|
|
111
|
+
// VTEX utility wrappers (signature-compatible stubs for custom loaders)
|
|
112
|
+
writeMultiFile(ctx, generateLibUtils(ctx));
|
|
113
|
+
|
|
114
|
+
// Replace Context-based useDevice with SSR-safe useSyncExternalStore version.
|
|
115
|
+
// @decocms/start shell-renders sections in a separate React root without
|
|
116
|
+
// Device.Provider, so the old createContext pattern throws during SSR.
|
|
117
|
+
writeFile(ctx, "src/contexts/device.tsx", generateDeviceContext());
|
|
118
|
+
|
|
119
|
+
// Location matcher — server-side geolocation matching
|
|
120
|
+
if (hasLocationMatcher(ctx)) {
|
|
121
|
+
writeFile(ctx, "src/matchers/location.ts", generateLocationMatcher());
|
|
122
|
+
}
|
|
97
123
|
|
|
98
124
|
// SiteTheme component (replaces apps/website/components/Theme.tsx)
|
|
99
125
|
const usesSiteTheme = ctx.files.some((f) => {
|
|
@@ -204,6 +230,17 @@ export default clx;
|
|
|
204
230
|
`;
|
|
205
231
|
}
|
|
206
232
|
|
|
233
|
+
function generateLoggerStub(): string {
|
|
234
|
+
return `export const logger = {
|
|
235
|
+
error: console.error,
|
|
236
|
+
warn: console.warn,
|
|
237
|
+
info: console.info,
|
|
238
|
+
debug: console.debug,
|
|
239
|
+
log: console.log,
|
|
240
|
+
};
|
|
241
|
+
`;
|
|
242
|
+
}
|
|
243
|
+
|
|
207
244
|
function generateDebounceShim(): string {
|
|
208
245
|
return `/** Debounce a function call — drop-in replacement for Deno std/async/debounce */
|
|
209
246
|
export function debounce<T extends (...args: any[]) => any>(
|
|
@@ -235,17 +272,102 @@ export default debounce;
|
|
|
235
272
|
}
|
|
236
273
|
|
|
237
274
|
function generateSignalShim(): string {
|
|
238
|
-
return `
|
|
275
|
+
return `import { useState, useRef, useEffect, useMemo, useCallback } from "react";
|
|
276
|
+
export { signal, type ReactiveSignal } from "@decocms/start/sdk/signal";
|
|
239
277
|
|
|
240
278
|
/** Run a function immediately. Kept for legacy module-level side effects. */
|
|
241
279
|
export function effect(fn: () => void | (() => void)): () => void {
|
|
242
280
|
const cleanup = fn();
|
|
243
281
|
return typeof cleanup === "function" ? cleanup : () => {};
|
|
244
282
|
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* React shim for @preact/signals' useSignal.
|
|
286
|
+
* Returns a mutable ref-like object with a .value property that triggers re-renders.
|
|
287
|
+
*/
|
|
288
|
+
export function useSignal<T>(initialValue: T): { value: T } {
|
|
289
|
+
const [value, setValue] = useState<T>(initialValue);
|
|
290
|
+
const ref = useRef(value);
|
|
291
|
+
ref.current = value;
|
|
292
|
+
return useMemo(
|
|
293
|
+
() => ({
|
|
294
|
+
get value() { return ref.current; },
|
|
295
|
+
set value(v: T) {
|
|
296
|
+
ref.current = v;
|
|
297
|
+
setValue(v);
|
|
298
|
+
},
|
|
299
|
+
}),
|
|
300
|
+
[],
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* React shim for @preact/signals' useComputed.
|
|
306
|
+
* Re-evaluates when deps change (but since we don't track signals, it runs every render).
|
|
307
|
+
*/
|
|
308
|
+
export function useComputed<T>(compute: () => T): { readonly value: T } {
|
|
309
|
+
const [value, setValue] = useState<T>(compute);
|
|
310
|
+
useEffect(() => { setValue(compute()); });
|
|
311
|
+
return useMemo(() => ({ get value() { return value; } }), [value]);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* React shim for @preact/signals' useSignalEffect.
|
|
316
|
+
* Runs the callback as a useEffect (no automatic signal tracking).
|
|
317
|
+
*/
|
|
318
|
+
export function useSignalEffect(cb: () => void | (() => void)): void {
|
|
319
|
+
useEffect(cb);
|
|
320
|
+
}
|
|
321
|
+
`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function generateDeviceContext(): string {
|
|
325
|
+
return `import { useSyncExternalStore } from "react";
|
|
326
|
+
|
|
327
|
+
const MOBILE_QUERY = "(max-width: 767px)";
|
|
328
|
+
|
|
329
|
+
function subscribe(cb: () => void) {
|
|
330
|
+
if (typeof window === "undefined") return () => {};
|
|
331
|
+
const mql = window.matchMedia(MOBILE_QUERY);
|
|
332
|
+
mql.addEventListener("change", cb);
|
|
333
|
+
return () => mql.removeEventListener("change", cb);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function getSnapshot(): boolean {
|
|
337
|
+
return window.matchMedia(MOBILE_QUERY).matches;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function getServerSnapshot(): boolean {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Reactive mobile detection based on viewport width via matchMedia.
|
|
346
|
+
* SSR defaults to desktop (false); hydrates to the real value on mount.
|
|
347
|
+
*
|
|
348
|
+
* For server-side device detection (UA-based), use the section loader
|
|
349
|
+
* pattern: registerSectionLoaders injects \`isMobile\` as a prop.
|
|
350
|
+
*/
|
|
351
|
+
export const useDevice = () => {
|
|
352
|
+
const isMobile = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
353
|
+
return { isMobile };
|
|
354
|
+
};
|
|
245
355
|
`;
|
|
246
356
|
}
|
|
247
357
|
|
|
248
358
|
function generateSiteApp(ctx: MigrationContext): string {
|
|
359
|
+
// Try to read source site.ts to extract secrets and AppContext shape
|
|
360
|
+
const secretFields = extractSecretFields(ctx);
|
|
361
|
+
|
|
362
|
+
let secretImport = "";
|
|
363
|
+
let secretTypes = "";
|
|
364
|
+
if (secretFields.length > 0) {
|
|
365
|
+
secretImport = `\nimport type { Secret } from "~/utils/secrets";\n`;
|
|
366
|
+
secretTypes = secretFields.map((f) => ` ${f}: Secret;`).join("\n");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const vtexAccount = ctx.vtexAccount || ctx.siteName;
|
|
370
|
+
|
|
249
371
|
return `export type Platform =
|
|
250
372
|
| "vtex"
|
|
251
373
|
| "vnda"
|
|
@@ -256,10 +378,177 @@ function generateSiteApp(ctx: MigrationContext): string {
|
|
|
256
378
|
| "custom";
|
|
257
379
|
|
|
258
380
|
export const _platform: Platform = "${ctx.platform}";
|
|
259
|
-
|
|
381
|
+
${secretImport}
|
|
260
382
|
export type AppContext = {
|
|
261
383
|
device: "mobile" | "desktop" | "tablet";
|
|
262
|
-
platform: Platform
|
|
384
|
+
platform: Platform;${secretTypes ? `\n${secretTypes}` : ""}${ctx.platform === "vtex" ? `\n account: string;` : ""}
|
|
263
385
|
};
|
|
264
386
|
`;
|
|
265
387
|
}
|
|
388
|
+
|
|
389
|
+
function extractSecretFields(ctx: MigrationContext): string[] {
|
|
390
|
+
const siteAppPaths = [
|
|
391
|
+
path.join(ctx.sourceDir, "apps", "site.ts"),
|
|
392
|
+
path.join(ctx.sourceDir, "src", "apps", "site.ts"),
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
for (const p of siteAppPaths) {
|
|
396
|
+
if (!fs.existsSync(p)) continue;
|
|
397
|
+
const content = fs.readFileSync(p, "utf-8");
|
|
398
|
+
const secretRe = /(\w+):\s*Secret\b/g;
|
|
399
|
+
const fields: string[] = [];
|
|
400
|
+
let match;
|
|
401
|
+
while ((match = secretRe.exec(content)) !== null) {
|
|
402
|
+
fields.push(match[1]);
|
|
403
|
+
}
|
|
404
|
+
return fields;
|
|
405
|
+
}
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function generateSecrets(ctx: MigrationContext): string {
|
|
410
|
+
const fields = extractSecretFields(ctx);
|
|
411
|
+
|
|
412
|
+
if (fields.length === 0) {
|
|
413
|
+
return `export interface Secret {
|
|
414
|
+
get(): string;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function envSecret(envKey: string): Secret {
|
|
418
|
+
return {
|
|
419
|
+
get: () => (process.env[envKey] as string) ?? "",
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export const secrets = {} as const;
|
|
424
|
+
`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const envKeyMap: Record<string, string> = {
|
|
428
|
+
GatewayApiKey: "GATEWAY_API_KEY",
|
|
429
|
+
topsortkey: "TOPSORT_KEY",
|
|
430
|
+
yourviewsToken: "YOURVIEWS_TOKEN",
|
|
431
|
+
pickuppointsAppKey: "PICKUPPOINTS_APP_KEY",
|
|
432
|
+
pickuppointsAppToken: "PICKUPPOINTS_APP_TOKEN",
|
|
433
|
+
SAPUser: "SAP_USER",
|
|
434
|
+
SAPPassword: "SAP_PASSWORD",
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const entries = fields.map((f) => {
|
|
438
|
+
const envKey = envKeyMap[f] || f.replace(/([A-Z])/g, "_$1").toUpperCase();
|
|
439
|
+
return ` ${f}: envSecret("${envKey}"),`;
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
return `export interface Secret {
|
|
443
|
+
get(): string;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function envSecret(envKey: string): Secret {
|
|
447
|
+
return {
|
|
448
|
+
get: () => (process.env[envKey] as string) ?? "",
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* All site-level secrets, sourced from Cloudflare Worker env bindings
|
|
454
|
+
* (process.env is polyfilled by nodejs_compat).
|
|
455
|
+
*
|
|
456
|
+
* Local dev: .dev.vars
|
|
457
|
+
* Production: \`wrangler secret put <KEY>\`
|
|
458
|
+
*/
|
|
459
|
+
export const secrets = {
|
|
460
|
+
${entries.join("\n")}
|
|
461
|
+
} as const;
|
|
462
|
+
`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function hasLocationMatcher(ctx: MigrationContext): boolean {
|
|
466
|
+
const dirs = [
|
|
467
|
+
path.join(ctx.sourceDir, "matchers"),
|
|
468
|
+
path.join(ctx.sourceDir, "src", "matchers"),
|
|
469
|
+
];
|
|
470
|
+
for (const dir of dirs) {
|
|
471
|
+
if (fs.existsSync(dir)) {
|
|
472
|
+
const files = fs.readdirSync(dir);
|
|
473
|
+
if (files.some((f) => f.includes("location"))) return true;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// Also check .deco/blocks for location matcher references
|
|
477
|
+
const blocksDir = path.join(ctx.sourceDir, ".deco", "blocks");
|
|
478
|
+
if (fs.existsSync(blocksDir)) {
|
|
479
|
+
for (const file of fs.readdirSync(blocksDir)) {
|
|
480
|
+
if (!file.endsWith(".json")) continue;
|
|
481
|
+
try {
|
|
482
|
+
const content = fs.readFileSync(path.join(blocksDir, file), "utf-8");
|
|
483
|
+
if (content.includes("website/matchers/location")) return true;
|
|
484
|
+
} catch { /* skip */ }
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function generateLocationMatcher(): string {
|
|
491
|
+
return `/**
|
|
492
|
+
* Server-side location matcher for website/matchers/location.ts
|
|
493
|
+
*
|
|
494
|
+
* Reads CF geolocation data injected as internal cookies by worker-entry.ts
|
|
495
|
+
* (__cf_geo_region, __cf_geo_country, __cf_geo_city) to evaluate location
|
|
496
|
+
* rules server-side.
|
|
497
|
+
*/
|
|
498
|
+
|
|
499
|
+
import { registerMatcher } from "@decocms/start/cms";
|
|
500
|
+
import type { MatcherContext } from "@decocms/start/cms";
|
|
501
|
+
|
|
502
|
+
const COUNTRY_NAME_TO_CODE: Record<string, string> = {
|
|
503
|
+
Brasil: "BR",
|
|
504
|
+
Brazil: "BR",
|
|
505
|
+
Argentina: "AR",
|
|
506
|
+
Chile: "CL",
|
|
507
|
+
Colombia: "CO",
|
|
508
|
+
Uruguay: "UY",
|
|
509
|
+
Paraguay: "PY",
|
|
510
|
+
Peru: "PE",
|
|
511
|
+
"United States": "US",
|
|
512
|
+
Portugal: "PT",
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
interface LocationRule {
|
|
516
|
+
regionCode?: string;
|
|
517
|
+
country?: string;
|
|
518
|
+
city?: string;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function matchesRule(loc: LocationRule, region: string, country: string, city: string): boolean {
|
|
522
|
+
if (loc.country) {
|
|
523
|
+
const code = COUNTRY_NAME_TO_CODE[loc.country] ?? loc.country;
|
|
524
|
+
if (code !== country) return false;
|
|
525
|
+
}
|
|
526
|
+
if (loc.regionCode && loc.regionCode !== region) return false;
|
|
527
|
+
if (loc.city && loc.city.toLowerCase() !== city.toLowerCase()) return false;
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function registerLocationMatcher(): void {
|
|
532
|
+
registerMatcher(
|
|
533
|
+
"website/matchers/location.ts",
|
|
534
|
+
(rule: Record<string, unknown>, ctx: MatcherContext): boolean => {
|
|
535
|
+
const includeLocations = (rule.includeLocations as LocationRule[] | undefined) ?? [];
|
|
536
|
+
const excludeLocations = (rule.excludeLocations as LocationRule[] | undefined) ?? [];
|
|
537
|
+
|
|
538
|
+
const cookies = ctx.cookies ?? {};
|
|
539
|
+
const region = cookies.__cf_geo_region ? decodeURIComponent(cookies.__cf_geo_region) : "";
|
|
540
|
+
const country = cookies.__cf_geo_country ? decodeURIComponent(cookies.__cf_geo_country) : "";
|
|
541
|
+
const city = cookies.__cf_geo_city ? decodeURIComponent(cookies.__cf_geo_city) : "";
|
|
542
|
+
|
|
543
|
+
if (excludeLocations.some((loc) => matchesRule(loc, region, country, city))) {
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (includeLocations.length === 0) return true;
|
|
548
|
+
|
|
549
|
+
return includeLocations.some((loc) => matchesRule(loc, region, country, city));
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
`;
|
|
554
|
+
}
|
|
@@ -38,11 +38,11 @@ function applyTransforms(content: string, filePath: string, ctx?: MigrationConte
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
// Pipeline: imports → jsx → fresh-apis → dead-code → deno-isms → tailwind
|
|
41
|
-
const pipeline = [
|
|
42
|
-
{ name: "imports", fn: transformImports },
|
|
41
|
+
const pipeline: Array<{ name: string; fn: (content: string) => TransformResult }> = [
|
|
42
|
+
{ name: "imports", fn: (c) => transformImports(c, ctx?.islandWrapperTargets) },
|
|
43
43
|
{ name: "jsx", fn: transformJsx },
|
|
44
44
|
{ name: "fresh-apis", fn: transformFreshApis },
|
|
45
|
-
{ name: "dead-code", fn: transformDeadCode },
|
|
45
|
+
{ name: "dead-code", fn: (c) => transformDeadCode(c, ctx?.platform) },
|
|
46
46
|
{ name: "deno-isms", fn: transformDenoIsms },
|
|
47
47
|
{ name: "tailwind", fn: transformTailwind },
|
|
48
48
|
];
|
|
@@ -86,6 +86,17 @@ export function transform(ctx: MigrationContext): void {
|
|
|
86
86
|
// Apply transforms
|
|
87
87
|
const result = applyTransforms(content, absPath, ctx, record.path);
|
|
88
88
|
|
|
89
|
+
// Fix section re-exports from wrapper islands — point to the wrapped component
|
|
90
|
+
const resolvedTarget = (record as any).__resolvedReExportTarget;
|
|
91
|
+
if (resolvedTarget && result.content.includes("~/components/")) {
|
|
92
|
+
// The import transform rewrote $store/islands/X → ~/components/X
|
|
93
|
+
// but for wrapper islands, the actual component is at a different path
|
|
94
|
+
const reExportRe = /from\s+"~\/components\/[^"]+"/g;
|
|
95
|
+
result.content = result.content.replace(reExportRe, `from "${resolvedTarget}"`);
|
|
96
|
+
result.notes.push(`Re-export resolved to wrapper target: ${resolvedTarget}`);
|
|
97
|
+
result.changed = true;
|
|
98
|
+
}
|
|
99
|
+
|
|
89
100
|
// Add manual review items
|
|
90
101
|
for (const note of result.notes) {
|
|
91
102
|
if (note.startsWith("[") && note.includes("MANUAL:")) {
|
|
@@ -115,6 +126,48 @@ export function transform(ctx: MigrationContext): void {
|
|
|
115
126
|
});
|
|
116
127
|
}
|
|
117
128
|
|
|
129
|
+
// Flag the legacy sections/Component.tsx dynamic-section loader.
|
|
130
|
+
// This file uses Deno-specific APIs (toFileUrl, import.meta.resolve)
|
|
131
|
+
// and the HTMX-driven `useComponent(component, props)` pattern, which
|
|
132
|
+
// do not run on Cloudflare Workers and have no equivalent in
|
|
133
|
+
// @decocms/start. The whole file must be deleted.
|
|
134
|
+
if (
|
|
135
|
+
/sections\/Component\.tsx?$/.test(record.path) ||
|
|
136
|
+
/sections\/Component\.tsx?$/.test(targetPath)
|
|
137
|
+
) {
|
|
138
|
+
ctx.manualReviewItems.push({
|
|
139
|
+
file: targetPath,
|
|
140
|
+
reason:
|
|
141
|
+
"sections/Component.tsx (Deno HTMX dynamic-section loader) is incompatible with TanStack Start / Cloudflare Workers. " +
|
|
142
|
+
"DELETE this file and migrate every `useComponent(...)` call site to one of: " +
|
|
143
|
+
"(a) local React state for client-side toggles, " +
|
|
144
|
+
"(b) `createServerFn` + `useMutation` for server actions, or " +
|
|
145
|
+
"(c) a direct `invoke` call (`~/server/invoke`) for ad-hoc loaders. " +
|
|
146
|
+
"See: deco-to-tanstack-migration skill, 'useComponent / partial sections' section.",
|
|
147
|
+
severity: "error",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Flag any import of useComponent — typically `import { useComponent } from "site/sections/Component.tsx"`.
|
|
152
|
+
// We also catch `from "../../sections/Component"` and similar relative variants.
|
|
153
|
+
if (
|
|
154
|
+
/\buseComponent\b/.test(result.content) &&
|
|
155
|
+
/from\s+["'][^"']*sections\/Component(?:\.tsx?)?["']/.test(result.content)
|
|
156
|
+
) {
|
|
157
|
+
ctx.manualReviewItems.push({
|
|
158
|
+
file: targetPath,
|
|
159
|
+
reason:
|
|
160
|
+
"useComponent({ ... }) call site detected. This is the HTMX-style dynamic-section render pattern " +
|
|
161
|
+
"that ships HTML fragments and swaps them client-side. It does not work on TanStack Start. " +
|
|
162
|
+
"Recipes: " +
|
|
163
|
+
"(1) Self-contained UI toggles → keep state in React (`useState` + event handlers); " +
|
|
164
|
+
"(2) Form submissions / mutations → `createServerFn` + `useMutation` (see casaevideo-storefront for canonical examples); " +
|
|
165
|
+
"(3) Ad-hoc data fetches → call the loader/action via `~/server/invoke` and store results in `useState`. " +
|
|
166
|
+
"Remove the import after refactoring, then delete `src/sections/Component.tsx`.",
|
|
167
|
+
severity: "error",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
118
171
|
if (ctx.dryRun) {
|
|
119
172
|
if (result.changed) {
|
|
120
173
|
log(ctx, `[DRY] Would transform: ${record.path} → ${targetPath}`);
|
|
@@ -1,6 +1,109 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
1
3
|
import type { MigrationContext } from "../types.ts";
|
|
2
4
|
import type { ExtractedTheme } from "../analyzers/theme-extractor.ts";
|
|
3
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Find the original site's custom CSS file.
|
|
8
|
+
* Deco sites typically have their custom CSS in:
|
|
9
|
+
* - tailwind.css (root level, combined directives + custom)
|
|
10
|
+
* - static/tailwind.css (compiled output — skip this)
|
|
11
|
+
* - static-{brand}/tailwind.css (compiled output — skip this)
|
|
12
|
+
* - styles/*.css
|
|
13
|
+
*/
|
|
14
|
+
function findOriginalCss(ctx: MigrationContext): string | null {
|
|
15
|
+
// Prefer root tailwind.css (has custom CSS + directives)
|
|
16
|
+
const rootCss = path.join(ctx.sourceDir, "tailwind.css");
|
|
17
|
+
if (fs.existsSync(rootCss)) {
|
|
18
|
+
return fs.readFileSync(rootCss, "utf-8");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Check for styles/ directory
|
|
22
|
+
const stylesDir = path.join(ctx.sourceDir, "styles");
|
|
23
|
+
if (fs.existsSync(stylesDir)) {
|
|
24
|
+
for (const file of fs.readdirSync(stylesDir)) {
|
|
25
|
+
if (file.endsWith(".css") && file !== "tailwind.css") {
|
|
26
|
+
return fs.readFileSync(path.join(stylesDir, file), "utf-8");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract custom CSS from the original site's CSS file.
|
|
36
|
+
* Strips TW3 directives (@tailwind base/components/utilities) and
|
|
37
|
+
* returns only the custom CSS (component overrides, @layer, @font-face, etc.)
|
|
38
|
+
*/
|
|
39
|
+
function extractCustomCss(rawCss: string): string {
|
|
40
|
+
return rawCss
|
|
41
|
+
// Remove Tailwind v3 directives (replaced by @import "tailwindcss")
|
|
42
|
+
.replace(/^@tailwind\s+(?:base|components|utilities)\s*;\s*$/gm, "")
|
|
43
|
+
// Remove empty lines left over
|
|
44
|
+
.replace(/^\s*\n/gm, "")
|
|
45
|
+
.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Transform @apply directives for TW3→TW4 compatibility.
|
|
50
|
+
* The tailwind.ts transform handles className= attributes in JSX,
|
|
51
|
+
* but @apply inside CSS also needs class renames.
|
|
52
|
+
*
|
|
53
|
+
* Also converts @apply with custom brand/theme colors to native CSS
|
|
54
|
+
* properties when the utility class might not be registered in TW4.
|
|
55
|
+
*/
|
|
56
|
+
function transformApplyDirectives(css: string): string {
|
|
57
|
+
return css
|
|
58
|
+
.replace(/@apply\s+([^;]+);/g, (_match, classes: string) => {
|
|
59
|
+
let fixed = classes;
|
|
60
|
+
// flex-grow-0 → grow-0
|
|
61
|
+
fixed = fixed.replace(/\bflex-grow-0\b/g, "grow-0");
|
|
62
|
+
fixed = fixed.replace(/\bflex-grow\b/g, "grow");
|
|
63
|
+
fixed = fixed.replace(/\bflex-shrink-0\b/g, "shrink-0");
|
|
64
|
+
fixed = fixed.replace(/\bflex-shrink\b/g, "shrink");
|
|
65
|
+
// transform → removed (auto in v4)
|
|
66
|
+
fixed = fixed.replace(/\btransform\b(?!-none)/g, "");
|
|
67
|
+
fixed = fixed.replace(/\bfilter\b/g, "");
|
|
68
|
+
// ring → ring-3
|
|
69
|
+
fixed = fixed.replace(/\bring\b(?!-)/g, "ring-3");
|
|
70
|
+
// Clean up multiple spaces
|
|
71
|
+
fixed = fixed.replace(/\s{2,}/g, " ").trim();
|
|
72
|
+
return `@apply ${fixed};`;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Test if a value looks like oklch coordinates (space-separated numbers,
|
|
78
|
+
* possibly with `/` for alpha). Examples: "0.5 0.2 30", "0.8 0.15 120 / 0.5"
|
|
79
|
+
* This distinguishes oklch coordinate values from hex colors.
|
|
80
|
+
*/
|
|
81
|
+
function isOklchCoordinates(val: string): boolean {
|
|
82
|
+
const trimmed = val.trim();
|
|
83
|
+
// oklch coordinates: 2-3 space-separated numbers, possibly with / alpha
|
|
84
|
+
// e.g. "0.5 0.2 30" or "0.85 0.15 120 / 0.5"
|
|
85
|
+
return /^[\d.]+\s+[\d.]+\s+[\d.]+(\s*\/\s*[\d.]+)?$/.test(trimmed);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract the primary font family from @font-face declarations in CSS.
|
|
90
|
+
* Returns the first font-family name found, or null.
|
|
91
|
+
*/
|
|
92
|
+
function extractPrimaryFontFromCss(css: string): string | null {
|
|
93
|
+
const fontFaceRe = /@font-face\s*\{[^}]*font-family:\s*["']?([^"';]+)["']?\s*;/g;
|
|
94
|
+
const families = new Set<string>();
|
|
95
|
+
let match;
|
|
96
|
+
while ((match = fontFaceRe.exec(css)) !== null) {
|
|
97
|
+
families.add(match[1].trim());
|
|
98
|
+
}
|
|
99
|
+
if (families.size === 0) return null;
|
|
100
|
+
// Prefer non-icon fonts
|
|
101
|
+
const nonIcon = [...families].filter(
|
|
102
|
+
(f) => !/icon|awesome|material/i.test(f),
|
|
103
|
+
);
|
|
104
|
+
return nonIcon[0] || [...families][0];
|
|
105
|
+
}
|
|
106
|
+
|
|
4
107
|
export function generateAppCss(ctx: MigrationContext, theme?: ExtractedTheme): string {
|
|
5
108
|
const sections: string[] = [];
|
|
6
109
|
|
|
@@ -36,7 +139,15 @@ ${colorLines}
|
|
|
36
139
|
}`);
|
|
37
140
|
|
|
38
141
|
// ── @theme block: Tailwind v3->v4 color migration ─────────────────
|
|
39
|
-
|
|
142
|
+
// Determine font family from theme, context, or original CSS @font-face
|
|
143
|
+
let fontFamily = theme?.fontFamily || ctx.fontFamily;
|
|
144
|
+
if (!fontFamily) {
|
|
145
|
+
const originalCssForFont = findOriginalCss(ctx);
|
|
146
|
+
if (originalCssForFont) {
|
|
147
|
+
const extracted = extractPrimaryFontFromCss(originalCssForFont);
|
|
148
|
+
if (extracted) fontFamily = extracted;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
40
151
|
let fontLine = "";
|
|
41
152
|
if (fontFamily) {
|
|
42
153
|
const firstFont = fontFamily.split(",")[0].trim().replace(/['"]/g, "");
|
|
@@ -55,6 +166,10 @@ ${colorLines}
|
|
|
55
166
|
--color-inherit: inherit;${fontLine}`;
|
|
56
167
|
|
|
57
168
|
// Add extracted theme variables to @theme
|
|
169
|
+
// Theme variables come from the CMS Theme section (.deco/blocks/Theme-*.json).
|
|
170
|
+
// The CMS sets CSS custom properties on :root at runtime, so @theme entries
|
|
171
|
+
// should reference var(--x) instead of hardcoding values. For oklch-formatted
|
|
172
|
+
// values (space-separated numbers like "0.5 0.2 30"), wrap with oklch().
|
|
58
173
|
const vars = theme?.variables ?? {};
|
|
59
174
|
if (Object.keys(vars).length > 0) {
|
|
60
175
|
themeBlock += `\n`;
|
|
@@ -68,7 +183,22 @@ ${colorLines}
|
|
|
68
183
|
for (const [prefix, entries] of Object.entries(grouped)) {
|
|
69
184
|
themeBlock += `\n /* ${prefix} */`;
|
|
70
185
|
for (const [k, v] of entries) {
|
|
71
|
-
|
|
186
|
+
if (!k.startsWith("--color-") && !k.startsWith("--font-") && !k.startsWith("--breakpoint-")) {
|
|
187
|
+
const val = v.trim();
|
|
188
|
+
const isColor = /^#[0-9a-fA-F]{3,8}$/.test(val) ||
|
|
189
|
+
/^(rgb|hsl|oklch|oklab)\(/.test(val) ||
|
|
190
|
+
/^(transparent|currentColor|inherit)$/.test(val) ||
|
|
191
|
+
isOklchCoordinates(val);
|
|
192
|
+
if (isColor) {
|
|
193
|
+
const varName = k.replace(/^--/, "");
|
|
194
|
+
const colorKey = `--color-${varName}`;
|
|
195
|
+
if (isOklchCoordinates(val)) {
|
|
196
|
+
themeBlock += `\n ${colorKey}: oklch(var(${k}));`;
|
|
197
|
+
} else {
|
|
198
|
+
themeBlock += `\n ${colorKey}: var(${k});`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
72
202
|
}
|
|
73
203
|
}
|
|
74
204
|
}
|
|
@@ -200,5 +330,22 @@ section[data-deferred="true"] {
|
|
|
200
330
|
display: none;
|
|
201
331
|
}`);
|
|
202
332
|
|
|
333
|
+
// ── Incorporate original site's custom CSS ────────────────────
|
|
334
|
+
// Instead of throwing away the site's CSS, we extract and append
|
|
335
|
+
// all custom rules (component overrides, @layer base, @font-face,
|
|
336
|
+
// typography utilities, feature-specific CSS, etc.)
|
|
337
|
+
const originalCss = findOriginalCss(ctx);
|
|
338
|
+
if (originalCss) {
|
|
339
|
+
const customCss = extractCustomCss(originalCss);
|
|
340
|
+
if (customCss) {
|
|
341
|
+
const transformed = transformApplyDirectives(customCss);
|
|
342
|
+
sections.push(`/* ═══════════════════════════════════════════════════════════════
|
|
343
|
+
Original site CSS (migrated from tailwind.css)
|
|
344
|
+
═══════════════════════════════════════════════════════════════ */
|
|
345
|
+
|
|
346
|
+
${transformed}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
203
350
|
return sections.join("\n\n") + "\n";
|
|
204
351
|
}
|