@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.
Files changed (37) hide show
  1. package/.cursor/skills/deco-to-tanstack-migration/SKILL.md +85 -12
  2. package/.cursor/skills/deco-to-tanstack-migration/references/gotchas.md +98 -0
  3. package/.cursor/skills/deco-to-tanstack-migration/templates/package-json.md +45 -25
  4. package/.cursor/skills/deco-to-tanstack-migration/templates/root-route.md +56 -39
  5. package/.cursor/skills/deco-to-tanstack-migration/templates/setup-ts.md +122 -141
  6. package/.releaserc.json +1 -0
  7. package/package.json +1 -1
  8. package/scripts/generate-blocks.ts +8 -5
  9. package/scripts/generate-loaders.ts +79 -12
  10. package/scripts/migrate/analyzers/island-classifier.ts +23 -0
  11. package/scripts/migrate/analyzers/section-metadata.ts +63 -7
  12. package/scripts/migrate/phase-analyze.ts +190 -11
  13. package/scripts/migrate/phase-cleanup.ts +1162 -7
  14. package/scripts/migrate/phase-scaffold.ts +294 -5
  15. package/scripts/migrate/phase-transform.ts +56 -3
  16. package/scripts/migrate/templates/app-css.ts +149 -2
  17. package/scripts/migrate/templates/commerce-loaders.ts +174 -69
  18. package/scripts/migrate/templates/lib-utils.ts +255 -0
  19. package/scripts/migrate/templates/package-json.ts +30 -22
  20. package/scripts/migrate/templates/routes.ts +81 -11
  21. package/scripts/migrate/templates/section-loaders.ts +369 -33
  22. package/scripts/migrate/templates/server-entry.ts +350 -80
  23. package/scripts/migrate/templates/setup.ts +78 -8
  24. package/scripts/migrate/templates/types-gen.ts +58 -0
  25. package/scripts/migrate/templates/ui-components.ts +47 -16
  26. package/scripts/migrate/templates/vite-config.ts +17 -6
  27. package/scripts/migrate/templates/wrangler.ts +3 -1
  28. package/scripts/migrate/transforms/dead-code.ts +330 -4
  29. package/scripts/migrate/transforms/deno-isms.ts +19 -0
  30. package/scripts/migrate/transforms/imports.ts +93 -30
  31. package/scripts/migrate/transforms/jsx.ts +79 -4
  32. package/scripts/migrate/transforms/section-conventions.ts +105 -3
  33. package/scripts/migrate/types.ts +9 -1
  34. package/src/cms/resolve.ts +12 -1
  35. package/src/sdk/useScript.ts +27 -6
  36. package/src/sdk/workerEntry.ts +11 -2
  37. 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
- // Apps
96
- writeFile(ctx, "src/apps/site.ts", generateSiteApp(ctx));
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 `export { signal, type ReactiveSignal } from "@decocms/start/sdk/signal";
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
- const fontFamily = theme?.fontFamily || ctx.fontFamily;
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
- themeBlock += `\n ${k}: ${v};`;
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
  }