@decocms/blocks-cli 7.20.10 → 7.21.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.20.10",
3
+ "version": "7.21.1",
4
4
  "type": "module",
5
5
  "description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "lint:unused": "knip"
31
31
  },
32
32
  "dependencies": {
33
- "@decocms/blocks": "7.20.10",
33
+ "@decocms/blocks": "7.21.1",
34
34
  "ts-morph": "^27.0.0",
35
35
  "tsx": "^4.22.5"
36
36
  },
@@ -0,0 +1,149 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { extractTailwindConfig } from "./tailwind-config";
6
+
7
+ let tmpDir: string;
8
+
9
+ beforeEach(() => {
10
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tw-config-test-"));
11
+ });
12
+
13
+ afterEach(() => {
14
+ fs.rmSync(tmpDir, { recursive: true, force: true });
15
+ });
16
+
17
+ function writeConfig(content: string) {
18
+ fs.writeFileSync(path.join(tmpDir, "tailwind.config.ts"), content, "utf-8");
19
+ }
20
+
21
+ describe("extractTailwindConfig", () => {
22
+ it("returns an empty extract when no config file exists", () => {
23
+ const result = extractTailwindConfig(tmpDir);
24
+ expect(result.colors).toEqual({});
25
+ expect(result.reviewItems).toEqual([]);
26
+ });
27
+
28
+ it("maps a DEFAULT sub-key to the bare parent color, not a literal -DEFAULT suffix", () => {
29
+ writeConfig(`
30
+ export default {
31
+ theme: {
32
+ extend: {
33
+ colors: {
34
+ primary: {
35
+ DEFAULT: "#112233",
36
+ 500: "#445566",
37
+ },
38
+ },
39
+ },
40
+ },
41
+ };
42
+ `);
43
+
44
+ const result = extractTailwindConfig(tmpDir);
45
+ expect(result.colors).toEqual({
46
+ primary: "#112233",
47
+ "primary-500": "#445566",
48
+ });
49
+ expect(result.colors["primary-DEFAULT"]).toBeUndefined();
50
+ });
51
+
52
+ it("flattens nested theme.extend.colors into dash-joined tokens", () => {
53
+ writeConfig(`
54
+ export default {
55
+ theme: {
56
+ extend: {
57
+ colors: {
58
+ perola: "#F5F0E8",
59
+ brand: {
60
+ 100: "#FFEFEF",
61
+ 500: "#B10200",
62
+ },
63
+ },
64
+ },
65
+ },
66
+ };
67
+ `);
68
+
69
+ const result = extractTailwindConfig(tmpDir);
70
+ expect(result.colors).toEqual({
71
+ perola: "#F5F0E8",
72
+ "brand-100": "#FFEFEF",
73
+ "brand-500": "#B10200",
74
+ });
75
+ expect(result.reviewItems).toEqual([]);
76
+ });
77
+
78
+ it("joins fontFamily array literals with a comma", () => {
79
+ writeConfig(`
80
+ export default {
81
+ theme: {
82
+ extend: {
83
+ fontFamily: {
84
+ sans: ["Bebas Neue", "sans-serif"],
85
+ },
86
+ },
87
+ },
88
+ };
89
+ `);
90
+
91
+ const result = extractTailwindConfig(tmpDir);
92
+ expect(result.fontFamily.sans).toBe("Bebas Neue, sans-serif");
93
+ });
94
+
95
+ it("splits safelist into literal strings and regex patterns", () => {
96
+ writeConfig(`
97
+ export default {
98
+ safelist: ["bg-red-500", /^grid-cols-/],
99
+ };
100
+ `);
101
+
102
+ const result = extractTailwindConfig(tmpDir);
103
+ expect(result.safelist).toEqual(["bg-red-500"]);
104
+ expect(result.safelistPatterns).toEqual(["/^grid-cols-/"]);
105
+ });
106
+
107
+ it("flags non-literal color entries as review items instead of dropping silently", () => {
108
+ writeConfig(`
109
+ const shared = { accent: "#000" };
110
+ export default {
111
+ theme: {
112
+ extend: {
113
+ colors: {
114
+ ...shared,
115
+ primary: someFn(),
116
+ },
117
+ },
118
+ },
119
+ };
120
+ `);
121
+
122
+ const result = extractTailwindConfig(tmpDir);
123
+ expect(result.colors.primary).toBeUndefined();
124
+ expect(result.reviewItems.length).toBeGreaterThan(0);
125
+ expect(result.reviewItems[0].severity).toBe("warning");
126
+ });
127
+
128
+ it("supports module.exports = {...} form", () => {
129
+ writeConfig(`
130
+ module.exports = {
131
+ theme: { extend: { colors: { primary: "#111111" } } },
132
+ };
133
+ `);
134
+
135
+ const result = extractTailwindConfig(tmpDir);
136
+ expect(result.colors).toEqual({ primary: "#111111" });
137
+ });
138
+
139
+ it("reports an error review item when the export shape is unrecognized", () => {
140
+ writeConfig(`
141
+ const config = buildConfig();
142
+ export default config;
143
+ `);
144
+
145
+ const result = extractTailwindConfig(tmpDir);
146
+ expect(result.colors).toEqual({});
147
+ expect(result.reviewItems.some((r) => r.severity === "error")).toBe(true);
148
+ });
149
+ });
@@ -0,0 +1,283 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { Project, SyntaxKind } from "ts-morph";
4
+ import type { Node, ObjectLiteralExpression } from "ts-morph";
5
+ import type { ReviewItem } from "../types";
6
+
7
+ /**
8
+ * Static extraction of the parts of a Fresh site's `tailwind.config.ts` that
9
+ * the migrator needs to port into the scaffolded Tailwind v4 `@theme` block:
10
+ * `theme.extend.colors`, `theme.extend.fontFamily`, `theme.extend.screens`,
11
+ * top-level `safelist`, and `plugins` (name only, for the report).
12
+ *
13
+ * This only resolves statically-analyzable literals (string/template
14
+ * literals, array literals, nested object literals). Anything built via a
15
+ * spread, function call, or imported constant is NOT evaluated — the config
16
+ * is deleted after this read, and executing arbitrary site code here would
17
+ * make the migration non-deterministic. Unresolvable nodes become
18
+ * `ReviewItem`s instead, so the gap is a visible finding, not a silent drop.
19
+ */
20
+ export interface TailwindConfigExtract {
21
+ /** Flattened color tokens, e.g. "brand-500" -> "#112233" */
22
+ colors: Record<string, string>;
23
+ /** Font family stacks, e.g. "sans" -> "Inter, ui-sans-serif, sans-serif" */
24
+ fontFamily: Record<string, string>;
25
+ /** Custom breakpoints, e.g. "3xl" -> "1920px" */
26
+ screens: Record<string, string>;
27
+ /** Literal safelist entries (string class names) */
28
+ safelist: string[];
29
+ /** Safelist regex patterns, kept as their source text */
30
+ safelistPatterns: string[];
31
+ /** Plugin expressions (source text), for the report only */
32
+ plugins: string[];
33
+ reviewItems: ReviewItem[];
34
+ }
35
+
36
+ const CONFIG_REL_PATH = "tailwind.config.ts";
37
+
38
+ function emptyExtract(): TailwindConfigExtract {
39
+ return {
40
+ colors: {},
41
+ fontFamily: {},
42
+ screens: {},
43
+ safelist: [],
44
+ safelistPatterns: [],
45
+ plugins: [],
46
+ reviewItems: [],
47
+ };
48
+ }
49
+
50
+ function stripQuotes(name: string): string {
51
+ return name.replace(/^["']|["']$/g, "");
52
+ }
53
+
54
+ /** Find the config's exported object literal, handling both `export default` and `module.exports =`. */
55
+ function findConfigObject(sourceFile: import("ts-morph").SourceFile): ObjectLiteralExpression | null {
56
+ const exportAssignment = sourceFile.getExportAssignments()[0];
57
+ if (exportAssignment) {
58
+ let expr: Node = exportAssignment.getExpression();
59
+ while (
60
+ expr.isKind(SyntaxKind.SatisfiesExpression) ||
61
+ expr.isKind(SyntaxKind.AsExpression) ||
62
+ expr.isKind(SyntaxKind.ParenthesizedExpression)
63
+ ) {
64
+ expr = expr.getExpression();
65
+ }
66
+ return expr.isKind(SyntaxKind.ObjectLiteralExpression) ? expr : null;
67
+ }
68
+
69
+ for (const stmt of sourceFile.getStatements()) {
70
+ if (!stmt.isKind(SyntaxKind.ExpressionStatement)) continue;
71
+ const expr = stmt.getExpression();
72
+ if (!expr.isKind(SyntaxKind.BinaryExpression)) continue;
73
+ if (expr.getLeft().getText() !== "module.exports") continue;
74
+ const right = expr.getRight();
75
+ if (right.isKind(SyntaxKind.ObjectLiteralExpression)) return right;
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ /** Get a direct child object-literal property by name, or undefined. */
82
+ function getObjectChild(
83
+ obj: ObjectLiteralExpression,
84
+ name: string,
85
+ ): ObjectLiteralExpression | undefined {
86
+ const prop = obj.getProperty(name);
87
+ if (!prop || !prop.isKind(SyntaxKind.PropertyAssignment)) return undefined;
88
+ const init = prop.getInitializer();
89
+ return init?.isKind(SyntaxKind.ObjectLiteralExpression) ? init : undefined;
90
+ }
91
+
92
+ function flattenColors(
93
+ obj: ObjectLiteralExpression,
94
+ prefix: string,
95
+ out: Record<string, string>,
96
+ reviewItems: ReviewItem[],
97
+ ): void {
98
+ for (const prop of obj.getProperties()) {
99
+ if (!prop.isKind(SyntaxKind.PropertyAssignment)) {
100
+ reviewItems.push({
101
+ file: CONFIG_REL_PATH,
102
+ reason: `theme.extend.colors has a non-literal entry (${prop.getKindName()}) — port manually to src/styles/app.css @theme`,
103
+ severity: "warning",
104
+ });
105
+ continue;
106
+ }
107
+ const name = stripQuotes(prop.getName());
108
+ // Tailwind's `DEFAULT` sub-key (e.g. `colors: { primary: { DEFAULT: "#..." , 500: "#..." } }`)
109
+ // means "this IS `--color-primary`", not a nested `--color-primary-DEFAULT` token — Tailwind
110
+ // v4 only maps `bg-primary`/`text-primary`/etc. to the bare `--color-primary` variable.
111
+ const key = name === "DEFAULT" ? prefix : prefix ? `${prefix}-${name}` : name;
112
+ const init = prop.getInitializer();
113
+ if (!init) continue;
114
+ if (!key) continue;
115
+
116
+ if (init.isKind(SyntaxKind.ObjectLiteralExpression)) {
117
+ flattenColors(init, key, out, reviewItems);
118
+ } else if (
119
+ init.isKind(SyntaxKind.StringLiteral) ||
120
+ init.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
121
+ ) {
122
+ out[key] = init.getLiteralText();
123
+ } else {
124
+ reviewItems.push({
125
+ file: CONFIG_REL_PATH,
126
+ reason: `Color token "${key}" is not a static string literal (${init.getKindName()}) — port manually to src/styles/app.css @theme`,
127
+ severity: "warning",
128
+ });
129
+ }
130
+ }
131
+ }
132
+
133
+ function extractFontFamily(
134
+ obj: ObjectLiteralExpression,
135
+ reviewItems: ReviewItem[],
136
+ ): Record<string, string> {
137
+ const result: Record<string, string> = {};
138
+ for (const prop of obj.getProperties()) {
139
+ if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
140
+ const name = stripQuotes(prop.getName());
141
+ const init = prop.getInitializer();
142
+ if (!init) continue;
143
+
144
+ if (init.isKind(SyntaxKind.ArrayLiteralExpression)) {
145
+ const parts: string[] = [];
146
+ for (const el of init.getElements()) {
147
+ if (
148
+ el.isKind(SyntaxKind.StringLiteral) ||
149
+ el.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
150
+ ) {
151
+ parts.push(el.getLiteralText());
152
+ } else {
153
+ reviewItems.push({
154
+ file: CONFIG_REL_PATH,
155
+ reason: `theme.extend.fontFamily.${name} has a non-literal stack entry (${el.getKindName()}) — port manually`,
156
+ severity: "warning",
157
+ });
158
+ }
159
+ }
160
+ if (parts.length > 0) result[name] = parts.join(", ");
161
+ } else if (
162
+ init.isKind(SyntaxKind.StringLiteral) ||
163
+ init.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
164
+ ) {
165
+ result[name] = init.getLiteralText();
166
+ }
167
+ }
168
+ return result;
169
+ }
170
+
171
+ function extractScreens(obj: ObjectLiteralExpression): Record<string, string> {
172
+ const result: Record<string, string> = {};
173
+ for (const prop of obj.getProperties()) {
174
+ if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
175
+ const name = stripQuotes(prop.getName());
176
+ const init = prop.getInitializer();
177
+ if (
178
+ init?.isKind(SyntaxKind.StringLiteral) ||
179
+ init?.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
180
+ ) {
181
+ result[name] = init.getLiteralText();
182
+ }
183
+ }
184
+ return result;
185
+ }
186
+
187
+ function extractSafelist(
188
+ arr: import("ts-morph").ArrayLiteralExpression,
189
+ reviewItems: ReviewItem[],
190
+ ): { literals: string[]; patterns: string[] } {
191
+ const literals: string[] = [];
192
+ const patterns: string[] = [];
193
+ for (const el of arr.getElements()) {
194
+ if (
195
+ el.isKind(SyntaxKind.StringLiteral) ||
196
+ el.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)
197
+ ) {
198
+ literals.push(el.getLiteralText());
199
+ } else if (el.isKind(SyntaxKind.RegularExpressionLiteral)) {
200
+ patterns.push(el.getText());
201
+ } else {
202
+ reviewItems.push({
203
+ file: CONFIG_REL_PATH,
204
+ reason: `safelist entry is not a static string or regex literal (${el.getKindName()}) — Tailwind v4 has no config-based safelist; port manually via @source inline(...) in src/styles/app.css`,
205
+ severity: "warning",
206
+ });
207
+ }
208
+ }
209
+ return { literals, patterns };
210
+ }
211
+
212
+ export function extractTailwindConfig(sourceDir: string): TailwindConfigExtract {
213
+ const configPath = path.join(sourceDir, CONFIG_REL_PATH);
214
+ if (!fs.existsSync(configPath)) return emptyExtract();
215
+
216
+ const reviewItems: ReviewItem[] = [];
217
+
218
+ let root: ObjectLiteralExpression | null;
219
+ try {
220
+ const project = new Project({ skipAddingFilesFromTsConfig: true });
221
+ const sourceFile = project.addSourceFileAtPath(configPath);
222
+ root = findConfigObject(sourceFile);
223
+ } catch (e) {
224
+ reviewItems.push({
225
+ file: CONFIG_REL_PATH,
226
+ reason: `Failed to parse tailwind.config.ts (${(e as Error).message}) — custom colors/fonts/safelist were NOT ported. Port manually to src/styles/app.css @theme.`,
227
+ severity: "error",
228
+ });
229
+ return { ...emptyExtract(), reviewItems };
230
+ }
231
+
232
+ if (!root) {
233
+ reviewItems.push({
234
+ file: CONFIG_REL_PATH,
235
+ reason:
236
+ "Could not statically locate the config's exported object (expected `export default {...}` or `module.exports = {...}`) — custom colors/fonts/safelist were NOT ported. Port manually to src/styles/app.css @theme.",
237
+ severity: "error",
238
+ });
239
+ return { ...emptyExtract(), reviewItems };
240
+ }
241
+
242
+ const themeObj = getObjectChild(root, "theme");
243
+ const extendObj = themeObj ? getObjectChild(themeObj, "extend") : undefined;
244
+
245
+ const colorsObj =
246
+ (extendObj && getObjectChild(extendObj, "colors")) ||
247
+ (themeObj && getObjectChild(themeObj, "colors"));
248
+ const colors: Record<string, string> = {};
249
+ if (colorsObj) flattenColors(colorsObj, "", colors, reviewItems);
250
+
251
+ const fontFamilyObj =
252
+ (extendObj && getObjectChild(extendObj, "fontFamily")) ||
253
+ (themeObj && getObjectChild(themeObj, "fontFamily"));
254
+ const fontFamily = fontFamilyObj ? extractFontFamily(fontFamilyObj, reviewItems) : {};
255
+
256
+ const screensObj =
257
+ (extendObj && getObjectChild(extendObj, "screens")) ||
258
+ (themeObj && getObjectChild(themeObj, "screens"));
259
+ const screens = screensObj ? extractScreens(screensObj) : {};
260
+
261
+ let safelist: string[] = [];
262
+ let safelistPatterns: string[] = [];
263
+ const safelistProp = root.getProperty("safelist");
264
+ if (safelistProp?.isKind(SyntaxKind.PropertyAssignment)) {
265
+ const init = safelistProp.getInitializer();
266
+ if (init?.isKind(SyntaxKind.ArrayLiteralExpression)) {
267
+ const r = extractSafelist(init, reviewItems);
268
+ safelist = r.literals;
269
+ safelistPatterns = r.patterns;
270
+ }
271
+ }
272
+
273
+ let plugins: string[] = [];
274
+ const pluginsProp = root.getProperty("plugins");
275
+ if (pluginsProp?.isKind(SyntaxKind.PropertyAssignment)) {
276
+ const init = pluginsProp.getInitializer();
277
+ if (init?.isKind(SyntaxKind.ArrayLiteralExpression)) {
278
+ plugins = init.getElements().map((el) => el.getText());
279
+ }
280
+ }
281
+
282
+ return { colors, fontFamily, screens, safelist, safelistPatterns, plugins, reviewItems };
283
+ }
@@ -0,0 +1,115 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import type { CompileRunResult } from "./phase-compile";
6
+ import { checkCssCompiles } from "./css-compile-check";
7
+
8
+ let tmpDir: string;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "css-compile-check-test-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(tmpDir, { recursive: true, force: true });
16
+ });
17
+
18
+ function fakeRunner(
19
+ impl: (cmd: string, cwd: string) => CompileRunResult,
20
+ ): { calls: string[]; runner: (cmd: string, cwd: string) => CompileRunResult } {
21
+ const calls: string[] = [];
22
+ const runner = (cmd: string, cwd: string) => {
23
+ calls.push(cmd);
24
+ return impl(cmd, cwd);
25
+ };
26
+ return { calls, runner };
27
+ }
28
+
29
+ describe("checkCssCompiles", () => {
30
+ it("no-ops when src/styles/app.css doesn't exist", () => {
31
+ const { runner, calls } = fakeRunner(() => ({ ok: true }));
32
+ const result = checkCssCompiles({ sourceDir: tmpDir }, runner);
33
+ expect(result.ran).toBe(false);
34
+ expect(result.passed).toBe(true);
35
+ expect(calls).toEqual([]);
36
+ });
37
+
38
+ it("reports a failure with the runner's captured output", () => {
39
+ fs.mkdirSync(path.join(tmpDir, "src", "styles"), { recursive: true });
40
+ fs.writeFileSync(path.join(tmpDir, "src", "styles", "app.css"), "@import \"tailwindcss\";\n");
41
+
42
+ const { runner, calls } = fakeRunner(() => ({
43
+ ok: false,
44
+ output: "Cannot apply unknown utility class `font-bebas-neue`",
45
+ }));
46
+ const result = checkCssCompiles({ sourceDir: tmpDir }, runner);
47
+ expect(result.ran).toBe(true);
48
+ expect(result.passed).toBe(false);
49
+ expect(result.output).toContain("font-bebas-neue");
50
+ expect(calls[0]).toContain("@tailwindcss/cli");
51
+ expect(calls[0]).toContain("src/styles/app.css");
52
+ });
53
+
54
+ it("reports success and scans for classes missing from the compiled output", () => {
55
+ fs.mkdirSync(path.join(tmpDir, "src", "styles"), { recursive: true });
56
+ fs.writeFileSync(path.join(tmpDir, "src", "styles", "app.css"), "@import \"tailwindcss\";\n");
57
+ fs.mkdirSync(path.join(tmpDir, "src", "components"), { recursive: true });
58
+ fs.writeFileSync(
59
+ path.join(tmpDir, "src", "components", "Foo.tsx"),
60
+ `export default () => <div className="container-pdp flex" />;\n`,
61
+ );
62
+
63
+ const { runner } = fakeRunner((cmd) => {
64
+ const outMatch = cmd.match(/-o\s+(\S+)/);
65
+ const outPath = outMatch![1];
66
+ // Simulate Tailwind having generated CSS for `flex` but not the
67
+ // dropped custom `container-pdp` utility.
68
+ fs.writeFileSync(outPath, ".flex { display: flex; }\n");
69
+ return { ok: true };
70
+ });
71
+
72
+ const result = checkCssCompiles({ sourceDir: tmpDir }, runner);
73
+ expect(result.passed).toBe(true);
74
+ expect(result.unmatchedClassWarnings).toContain("container-pdp");
75
+ expect(result.unmatchedClassWarnings).not.toContain("flex");
76
+ });
77
+
78
+ it("does not flag classes containing variants/brackets/opacity modifiers (escaping is unreliable)", () => {
79
+ fs.mkdirSync(path.join(tmpDir, "src", "styles"), { recursive: true });
80
+ fs.writeFileSync(path.join(tmpDir, "src", "styles", "app.css"), "@import \"tailwindcss\";\n");
81
+ fs.mkdirSync(path.join(tmpDir, "src", "components"), { recursive: true });
82
+ fs.writeFileSync(
83
+ path.join(tmpDir, "src", "components", "Foo.tsx"),
84
+ `export default () => <div className="hover:underline bg-black/20 w-[100px]" />;\n`,
85
+ );
86
+
87
+ const { runner } = fakeRunner((cmd) => {
88
+ const outPath = cmd.match(/-o\s+(\S+)/)![1];
89
+ fs.writeFileSync(outPath, "");
90
+ return { ok: true };
91
+ });
92
+
93
+ const result = checkCssCompiles({ sourceDir: tmpDir }, runner);
94
+ expect(result.unmatchedClassWarnings).toEqual([]);
95
+ });
96
+
97
+ it("does not treat a data-class attribute's value as a className token", () => {
98
+ fs.mkdirSync(path.join(tmpDir, "src", "styles"), { recursive: true });
99
+ fs.writeFileSync(path.join(tmpDir, "src", "styles", "app.css"), "@import \"tailwindcss\";\n");
100
+ fs.mkdirSync(path.join(tmpDir, "src", "components"), { recursive: true });
101
+ fs.writeFileSync(
102
+ path.join(tmpDir, "src", "components", "Foo.tsx"),
103
+ `export default () => <div data-class="tracking-widget" className="flex" />;\n`,
104
+ );
105
+
106
+ const { runner } = fakeRunner((cmd) => {
107
+ const outPath = cmd.match(/-o\s+(\S+)/)![1];
108
+ fs.writeFileSync(outPath, ".flex { display: flex; }\n");
109
+ return { ok: true };
110
+ });
111
+
112
+ const result = checkCssCompiles({ sourceDir: tmpDir }, runner);
113
+ expect(result.unmatchedClassWarnings).not.toContain("tracking-widget");
114
+ });
115
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Real Tailwind v4 CSS compilation, run as part of phase-compile.
3
+ *
4
+ * Every other CSS-migration transform is a heuristic (regex renames,
5
+ * static tailwind.config.ts extraction, best-effort oklch fixes). This is
6
+ * the safety net: it actually compiles `src/styles/app.css` with the
7
+ * site's own installed `tailwindcss` via the official `@tailwindcss/cli`,
8
+ * so an unknown-utility-class error (the exact failure mode of
9
+ * decocms/blocks#369 / skill gotcha #48) surfaces during migration instead
10
+ * of at runtime in the browser.
11
+ *
12
+ * Uses the CLI (not `@tailwindcss/node`'s internal compile() API) on
13
+ * purpose — it's the officially documented, version-stable entry point,
14
+ * where the internal package APIs are not.
15
+ */
16
+
17
+ import * as fs from "node:fs";
18
+ import * as os from "node:os";
19
+ import * as path from "node:path";
20
+ import type { CompileRunResult } from "./phase-compile";
21
+ import type { MigrationContext } from "./types";
22
+
23
+ export interface CssCompileResult {
24
+ ran: boolean;
25
+ passed: boolean;
26
+ output?: string;
27
+ /** Best-effort warnings — classes used in src/ that don't appear as selectors in the compiled CSS. Never fails the build. */
28
+ unmatchedClassWarnings: string[];
29
+ }
30
+
31
+ const APP_CSS_REL_PATH = "src/styles/app.css";
32
+
33
+ /**
34
+ * Compile `src/styles/app.css` with the site's own Tailwind v4 install.
35
+ * No-ops (ran: false) if the file or `@tailwindcss/cli` isn't present —
36
+ * callers should treat that the same as "nothing to check", not a failure.
37
+ */
38
+ export function checkCssCompiles(
39
+ ctx: Pick<MigrationContext, "sourceDir">,
40
+ runner: (cmd: string, cwd: string) => CompileRunResult,
41
+ ): CssCompileResult {
42
+ const cssPath = path.join(ctx.sourceDir, APP_CSS_REL_PATH);
43
+ if (!fs.existsSync(cssPath)) {
44
+ return { ran: false, passed: true, unmatchedClassWarnings: [] };
45
+ }
46
+
47
+ const outPath = path.join(
48
+ os.tmpdir(),
49
+ `deco-migrate-css-check-${process.pid}-${Date.now()}.css`,
50
+ );
51
+
52
+ try {
53
+ const result = runner(
54
+ `npx @tailwindcss/cli -i ${APP_CSS_REL_PATH} -o ${outPath}`,
55
+ ctx.sourceDir,
56
+ );
57
+
58
+ if (!result.ok) {
59
+ return { ran: true, passed: false, output: result.output, unmatchedClassWarnings: [] };
60
+ }
61
+
62
+ let unmatchedClassWarnings: string[] = [];
63
+ try {
64
+ const compiledCss = fs.readFileSync(outPath, "utf-8");
65
+ unmatchedClassWarnings = scanUnmatchedUtilityClasses(ctx.sourceDir, compiledCss);
66
+ } catch {
67
+ // Coverage scan is best-effort only — never let it fail the check.
68
+ }
69
+
70
+ return { ran: true, passed: true, unmatchedClassWarnings };
71
+ } finally {
72
+ try {
73
+ fs.rmSync(outPath, { force: true });
74
+ } catch {
75
+ // tmp cleanup best-effort
76
+ }
77
+ }
78
+ }
79
+
80
+ // Negative lookbehind guards against matching mid-attribute-name, e.g.
81
+ // `data-class="..."` — without it, "class" (a substring of "data-class")
82
+ // would match and pull an unrelated attribute value into the class scan.
83
+ const CLASS_ATTR_RE = /(?<![\w-])(?:className|class)\s*=\s*"([^"]+)"/g;
84
+
85
+ /**
86
+ * Best-effort cross-check: className tokens used in src/ that don't show
87
+ * up as a CSS selector anywhere in the compiled output. Purely
88
+ * informational — dynamic class construction (template literals, `clx()`
89
+ * with variables, conditional classes) produces false positives here, so
90
+ * this never fails the migration, only surfaces warnings for a human to
91
+ * triage.
92
+ */
93
+ function scanUnmatchedUtilityClasses(sourceDir: string, compiledCss: string): string[] {
94
+ const srcDir = path.join(sourceDir, "src");
95
+ if (!fs.existsSync(srcDir)) return [];
96
+
97
+ const usedClasses = new Set<string>();
98
+ const walk = (dir: string) => {
99
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
100
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
101
+ const full = path.join(dir, entry.name);
102
+ if (entry.isDirectory()) {
103
+ walk(full);
104
+ continue;
105
+ }
106
+ if (!/\.(tsx|ts)$/.test(entry.name)) continue;
107
+ const content = fs.readFileSync(full, "utf-8");
108
+ let m: RegExpExecArray | null;
109
+ const re = new RegExp(CLASS_ATTR_RE);
110
+ while ((m = re.exec(content)) !== null) {
111
+ for (const cls of m[1].split(/\s+/).filter(Boolean)) {
112
+ usedClasses.add(cls);
113
+ }
114
+ }
115
+ }
116
+ };
117
+ walk(srcDir);
118
+
119
+ const warnings: string[] = [];
120
+ for (const cls of usedClasses) {
121
+ // Only cross-check plain word/hyphen tokens (no variants, arbitrary
122
+ // values, opacity modifiers, or template-interpolated fragments) —
123
+ // Tailwind v4 escapes those characters in the compiled selector
124
+ // (`.hover\:underline`, `.bg-black\/20`), which would make a naive
125
+ // string match unreliable. Plain tokens are exactly the case that
126
+ // matters most here: a dropped custom color/font/component class
127
+ // (e.g. `font-bebas-neue`, `container-pdp`) compiles to nothing,
128
+ // silently, with no error from the CLI.
129
+ if (/[:\[\]{}$/.]/.test(cls)) continue;
130
+ const selectorRe = new RegExp(`\\.${cls}(?:[^\\w-]|$)`);
131
+ if (!selectorRe.test(compiledCss)) {
132
+ warnings.push(cls);
133
+ }
134
+ }
135
+
136
+ return warnings.sort();
137
+ }