@fragments-sdk/cli 0.14.3 → 0.15.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/README.md +0 -3
- package/dist/{ai-client-I6MDWNYA.js → ai-client-LSLQGOMM.js} +1 -2
- package/dist/bin.js +4745 -3817
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-TXFCEDOC.js → chunk-2WXKALIG.js} +2 -2
- package/dist/{chunk-I34BC3CU.js → chunk-32LIWN2P.js} +1006 -3
- package/dist/chunk-32LIWN2P.js.map +1 -0
- package/dist/chunk-5JF26E55.js +1255 -0
- package/dist/chunk-5JF26E55.js.map +1 -0
- package/dist/{chunk-APTQIBS5.js → chunk-6SQPP47U.js} +153 -1342
- package/dist/chunk-6SQPP47U.js.map +1 -0
- package/dist/chunk-7DZC4YEV.js +294 -0
- package/dist/chunk-7DZC4YEV.js.map +1 -0
- package/dist/{chunk-PJT5IZ37.js → chunk-BJE3425I.js} +19 -52
- package/dist/{chunk-PJT5IZ37.js.map → chunk-BJE3425I.js.map} +1 -1
- package/dist/{chunk-55KERLWL.js → chunk-HQ6A6DTV.js} +1587 -1073
- package/dist/chunk-HQ6A6DTV.js.map +1 -0
- package/dist/chunk-MHIBEEW4.js +511 -0
- package/dist/chunk-MHIBEEW4.js.map +1 -0
- package/dist/{chunk-5A6X2Y73.js → chunk-ONUP6Z4W.js} +25 -13
- package/dist/chunk-ONUP6Z4W.js.map +1 -0
- package/dist/chunk-QCN35LJU.js +630 -0
- package/dist/chunk-QCN35LJU.js.map +1 -0
- package/dist/chunk-T47OLCSF.js +36 -0
- package/dist/chunk-T47OLCSF.js.map +1 -0
- package/dist/codebase-scanner-MQHUZC2G.js +21 -0
- package/dist/converter-7XM3Y6NJ.js +33 -0
- package/dist/converter-7XM3Y6NJ.js.map +1 -0
- package/dist/core/index.js +43 -2
- package/dist/create-IH4R45GE.js +806 -0
- package/dist/create-IH4R45GE.js.map +1 -0
- package/dist/{generate-RYWIPDN2.js → generate-PVOLUAAC.js} +4 -6
- package/dist/{generate-RYWIPDN2.js.map → generate-PVOLUAAC.js.map} +1 -1
- package/dist/govern-scan-OYFZYOQW.js +413 -0
- package/dist/govern-scan-OYFZYOQW.js.map +1 -0
- package/dist/index.d.ts +4 -23
- package/dist/index.js +15 -14
- package/dist/index.js.map +1 -1
- package/dist/{init-WRUSW7R5.js → init-SSGUSP7Z.js} +131 -129
- package/dist/init-SSGUSP7Z.js.map +1 -0
- package/dist/{init-cloud-REQ3XLHO.js → init-cloud-3DNKPWFB.js} +30 -5
- package/dist/{init-cloud-REQ3XLHO.js.map → init-cloud-3DNKPWFB.js.map} +1 -1
- package/dist/mcp-bin.js +5 -37
- package/dist/mcp-bin.js.map +1 -1
- package/dist/node-37AUE74M.js +65 -0
- package/dist/push-contracts-WY32TFP6.js +84 -0
- package/dist/push-contracts-WY32TFP6.js.map +1 -0
- package/dist/scan-PKSYSTRR.js +15 -0
- package/dist/{scan-generate-TFZVL3BT.js → scan-generate-VY27PIOX.js} +340 -52
- package/dist/scan-generate-VY27PIOX.js.map +1 -0
- package/dist/scanner-4KZNOXAK.js +12 -0
- package/dist/{service-HKJ6B7P7.js → service-QJGWUIVL.js} +41 -30
- package/dist/{snapshot-C5DYIGIV.js → snapshot-WIJMEIFT.js} +2 -3
- package/dist/{snapshot-C5DYIGIV.js.map → snapshot-WIJMEIFT.js.map} +1 -1
- package/dist/{static-viewer-DUVC4UIM.js → static-viewer-7QIBQZRC.js} +3 -4
- package/dist/static-viewer-7QIBQZRC.js.map +1 -0
- package/dist/{test-JW7JIDFG.js → test-64Z5BKBA.js} +4 -7
- package/dist/{test-JW7JIDFG.js.map → test-64Z5BKBA.js.map} +1 -1
- package/dist/token-normalizer-TEPOVBPV.js +312 -0
- package/dist/token-normalizer-TEPOVBPV.js.map +1 -0
- package/dist/token-parser-32KOIOFN.js +22 -0
- package/dist/token-parser-32KOIOFN.js.map +1 -0
- package/dist/{tokens-KE73G5JC.js → tokens-NZWFQIAB.js} +10 -9
- package/dist/{tokens-KE73G5JC.js.map → tokens-NZWFQIAB.js.map} +1 -1
- package/dist/tokens-generate-5JQSJ27E.js +85 -0
- package/dist/tokens-generate-5JQSJ27E.js.map +1 -0
- package/dist/tokens-push-HY3KO36V.js +148 -0
- package/dist/tokens-push-HY3KO36V.js.map +1 -0
- package/package.json +8 -6
- package/src/bin.ts +300 -48
- package/src/commands/__fixtures__/shadcn-label-wrapper/package.json +7 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.contract.json +42 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.tsx +11 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.contract.json +20 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.tsx +14 -0
- package/src/commands/__fixtures__/shadcn-label-wrapper/tsconfig.app.json +23 -0
- package/src/commands/__tests__/build-freshness.test.ts +231 -0
- package/src/commands/__tests__/create.test.ts +71 -0
- package/src/commands/__tests__/drift-sync.test.ts +1 -1
- package/src/commands/__tests__/govern.test.ts +258 -0
- package/src/commands/__tests__/init.test.ts +113 -0
- package/src/commands/__tests__/scan-generate.test.ts +189 -70
- package/src/commands/__tests__/verify.test.ts +91 -0
- package/src/commands/build.ts +54 -1
- package/src/commands/context.ts +1 -1
- package/src/commands/create.ts +536 -0
- package/src/commands/discover.ts +151 -0
- package/src/commands/doctor.ts +3 -2
- package/src/commands/enhance.ts +3 -1
- package/src/commands/govern-scan.ts +565 -0
- package/src/commands/govern.ts +67 -4
- package/src/commands/init-cloud.ts +32 -4
- package/src/commands/init.ts +152 -28
- package/src/commands/inspect.ts +290 -0
- package/src/commands/migrate-contract.ts +85 -0
- package/src/commands/push-contracts.ts +112 -0
- package/src/commands/scan-generate.ts +439 -51
- package/src/commands/scan.ts +14 -0
- package/src/commands/setup.ts +27 -50
- package/src/commands/sync.ts +2 -2
- package/src/commands/tokens-generate.ts +113 -0
- package/src/commands/tokens-push.ts +199 -0
- package/src/commands/verify.ts +195 -1
- package/src/core/__fixtures__/shadcn-input/input.tsx +7 -0
- package/src/core/__fixtures__/shadcn-input/tsconfig.json +14 -0
- package/src/core/__fixtures__/shadcn-label/label.tsx +11 -0
- package/src/core/__fixtures__/shadcn-label/primitive.tsx +14 -0
- package/src/core/__fixtures__/shadcn-label/tsconfig.json +14 -0
- package/src/core/__fixtures__/shadcn-radix-label/label.tsx +11 -0
- package/src/core/__fixtures__/shadcn-radix-label/node_modules/radix-ui/index.d.ts +12 -0
- package/src/core/__fixtures__/shadcn-radix-label/tsconfig.json +14 -0
- package/src/core/__tests__/contract-parity.test.ts +316 -0
- package/src/core/__tests__/token-resolver.test.ts +1 -1
- package/src/core/component-extractor.test.ts +40 -1
- package/src/core/config.ts +2 -1
- package/src/core/discovery.ts +13 -2
- package/src/core/drift-verifier.ts +123 -0
- package/src/core/extractor-adapter.ts +80 -0
- package/src/index.ts +3 -3
- package/src/mcp/__tests__/projectFields.test.ts +1 -1
- package/src/mcp/utils.ts +1 -50
- package/src/migrate/converter.ts +3 -3
- package/src/migrate/fragment-to-contract.ts +253 -0
- package/src/migrate/report.ts +1 -1
- package/src/scripts/token-benchmark.ts +121 -0
- package/src/service/__tests__/props-extractor.test.ts +94 -0
- package/src/service/__tests__/token-normalizer.test.ts +690 -0
- package/src/service/ast-utils.ts +4 -23
- package/src/service/babel-config.ts +23 -0
- package/src/service/enhance/converter.ts +61 -0
- package/src/service/enhance/props-extractor.ts +25 -8
- package/src/service/enhance/scanner.ts +5 -24
- package/src/service/index.ts +8 -0
- package/src/service/snippet-validation.ts +9 -3
- package/src/service/tailwind-v4-parser.ts +314 -0
- package/src/service/token-normalizer.ts +510 -0
- package/src/service/token-parser.ts +56 -0
- package/src/setup.ts +10 -39
- package/src/shared/index.ts +1 -0
- package/src/shared/project-fields.ts +46 -0
- package/src/theme/__tests__/component-contrast.test.ts +2 -2
- package/src/theme/__tests__/serializer.test.ts +1 -1
- package/src/theme/generator.ts +16 -1
- package/src/theme/schema.ts +8 -0
- package/src/theme/serializer.ts +13 -9
- package/src/theme/types.ts +8 -0
- package/src/validators.ts +1 -2
- package/src/viewer/__tests__/viewer-integration.test.ts +8 -8
- package/src/viewer/style-utils.ts +27 -412
- package/src/viewer/vite-plugin.ts +2 -2
- package/dist/chunk-55KERLWL.js.map +0 -1
- package/dist/chunk-5A6X2Y73.js.map +0 -1
- package/dist/chunk-APTQIBS5.js.map +0 -1
- package/dist/chunk-EYXVAMEX.js +0 -626
- package/dist/chunk-EYXVAMEX.js.map +0 -1
- package/dist/chunk-I34BC3CU.js.map +0 -1
- package/dist/chunk-LOYS64QS.js +0 -2453
- package/dist/chunk-LOYS64QS.js.map +0 -1
- package/dist/chunk-Z7EY4VHE.js +0 -50
- package/dist/chunk-ZKTFKHWN.js +0 -324
- package/dist/chunk-ZKTFKHWN.js.map +0 -1
- package/dist/discovery-VDANZAJ2.js +0 -28
- package/dist/init-WRUSW7R5.js.map +0 -1
- package/dist/sass.node-4XJK6YBF.js +0 -130708
- package/dist/sass.node-4XJK6YBF.js.map +0 -1
- package/dist/scan-YJHQIRKG.js +0 -14
- package/dist/scan-generate-TFZVL3BT.js.map +0 -1
- package/dist/viewer-2TZS3NDL.js +0 -2730
- package/dist/viewer-2TZS3NDL.js.map +0 -1
- package/src/build.ts +0 -612
- package/src/commands/dev.ts +0 -107
- package/src/core/auto-props.ts +0 -464
- package/src/core/component-extractor.ts +0 -1030
- package/src/core/token-resolver.ts +0 -155
- /package/dist/{ai-client-I6MDWNYA.js.map → ai-client-LSLQGOMM.js.map} +0 -0
- /package/dist/{chunk-TXFCEDOC.js.map → chunk-2WXKALIG.js.map} +0 -0
- /package/dist/{chunk-Z7EY4VHE.js.map → codebase-scanner-MQHUZC2G.js.map} +0 -0
- /package/dist/{discovery-VDANZAJ2.js.map → node-37AUE74M.js.map} +0 -0
- /package/dist/{scan-YJHQIRKG.js.map → scan-PKSYSTRR.js.map} +0 -0
- /package/dist/{service-HKJ6B7P7.js.map → scanner-4KZNOXAK.js.map} +0 -0
- /package/dist/{static-viewer-DUVC4UIM.js.map → service-QJGWUIVL.js.map} +0 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Normalizer
|
|
3
|
+
*
|
|
4
|
+
* One normalization layer that produces NormalizedToken[] regardless of source:
|
|
5
|
+
* - CSS custom properties (from token-parser.ts)
|
|
6
|
+
* - Tailwind theme tokens (from tailwind.config.{js,ts,mjs,cjs})
|
|
7
|
+
*
|
|
8
|
+
* Also provides NormalizedTokenLookup implementing the core TokenLookup
|
|
9
|
+
* interface so the canonical comparison engine can consume any token source.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { createJiti } from "jiti";
|
|
15
|
+
import { parseColor } from "@fragments-sdk/core";
|
|
16
|
+
import type {
|
|
17
|
+
DesignToken,
|
|
18
|
+
EnhancedStyleDiffItem,
|
|
19
|
+
NormalizedToken,
|
|
20
|
+
TokenCategory,
|
|
21
|
+
TokenLookup,
|
|
22
|
+
TokenUsageSummary,
|
|
23
|
+
} from "@fragments-sdk/core";
|
|
24
|
+
|
|
25
|
+
// ─── CSS Custom Property Normalization ───────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Convert parsed DesignToken[] (CSS custom properties) to NormalizedToken[].
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeCSSVarTokens(tokens: DesignToken[]): NormalizedToken[] {
|
|
31
|
+
return tokens.map((token) => ({
|
|
32
|
+
name: token.name,
|
|
33
|
+
value: token.resolvedValue,
|
|
34
|
+
category: token.category,
|
|
35
|
+
source: "css-var" as const,
|
|
36
|
+
originalName: token.name,
|
|
37
|
+
theme: token.theme,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Tailwind Theme Normalization ────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Tailwind theme key → NormalizedToken category mapping.
|
|
45
|
+
* Only keys that map to meaningful token categories are listed.
|
|
46
|
+
*/
|
|
47
|
+
const TAILWIND_CATEGORY_MAP: Record<string, TokenCategory> = {
|
|
48
|
+
colors: "color",
|
|
49
|
+
backgroundColor: "color",
|
|
50
|
+
textColor: "color",
|
|
51
|
+
borderColor: "color",
|
|
52
|
+
fill: "color",
|
|
53
|
+
stroke: "color",
|
|
54
|
+
accentColor: "color",
|
|
55
|
+
ringColor: "color",
|
|
56
|
+
outlineColor: "color",
|
|
57
|
+
|
|
58
|
+
spacing: "spacing",
|
|
59
|
+
margin: "spacing",
|
|
60
|
+
padding: "spacing",
|
|
61
|
+
gap: "spacing",
|
|
62
|
+
space: "spacing",
|
|
63
|
+
inset: "spacing",
|
|
64
|
+
|
|
65
|
+
fontSize: "typography",
|
|
66
|
+
fontFamily: "typography",
|
|
67
|
+
fontWeight: "typography",
|
|
68
|
+
lineHeight: "typography",
|
|
69
|
+
letterSpacing: "typography",
|
|
70
|
+
|
|
71
|
+
borderRadius: "radius",
|
|
72
|
+
|
|
73
|
+
borderWidth: "border",
|
|
74
|
+
|
|
75
|
+
boxShadow: "shadow",
|
|
76
|
+
|
|
77
|
+
width: "sizing",
|
|
78
|
+
height: "sizing",
|
|
79
|
+
maxWidth: "sizing",
|
|
80
|
+
maxHeight: "sizing",
|
|
81
|
+
minWidth: "sizing",
|
|
82
|
+
minHeight: "sizing",
|
|
83
|
+
size: "sizing",
|
|
84
|
+
|
|
85
|
+
zIndex: "z-index",
|
|
86
|
+
|
|
87
|
+
animation: "animation",
|
|
88
|
+
transitionDuration: "animation",
|
|
89
|
+
transitionTimingFunction: "animation",
|
|
90
|
+
transitionDelay: "animation",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Convert a Tailwind theme object into NormalizedToken[].
|
|
95
|
+
*
|
|
96
|
+
* Handles:
|
|
97
|
+
* - Flat values: `{ primary: '#0051c2' }` → `colors.primary`
|
|
98
|
+
* - Nested values: `{ blue: { 50: '#eff6ff', 500: '#3b82f6' } }` → `colors.blue.50`
|
|
99
|
+
* - DEFAULT key: `{ blue: { DEFAULT: '#3b82f6' } }` → `colors.blue`
|
|
100
|
+
* - Array values for fontFamily: `{ sans: ['Inter', 'sans-serif'] }` → joined
|
|
101
|
+
* - Tuple values for fontSize: `{ sm: ['14px', { lineHeight: '20px' }] }` → first element
|
|
102
|
+
*/
|
|
103
|
+
export function normalizeTailwindTheme(
|
|
104
|
+
theme: Record<string, unknown>,
|
|
105
|
+
options: {
|
|
106
|
+
/** Source identifier (default: "tailwind") */
|
|
107
|
+
source?: string;
|
|
108
|
+
/** Theme name (default: "default") */
|
|
109
|
+
defaultTheme?: string;
|
|
110
|
+
} = {}
|
|
111
|
+
): NormalizedToken[] {
|
|
112
|
+
const { source = "tailwind", defaultTheme = "default" } = options;
|
|
113
|
+
const tokens: NormalizedToken[] = [];
|
|
114
|
+
|
|
115
|
+
for (const [themeKey, themeValue] of Object.entries(theme)) {
|
|
116
|
+
const category = TAILWIND_CATEGORY_MAP[themeKey];
|
|
117
|
+
if (!category || themeValue == null || typeof themeValue === "function") {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof themeValue !== "object") {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
flattenThemeValue(
|
|
126
|
+
themeValue as Record<string, unknown>,
|
|
127
|
+
themeKey,
|
|
128
|
+
category,
|
|
129
|
+
source,
|
|
130
|
+
defaultTheme,
|
|
131
|
+
tokens
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return tokens;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Recursively flatten a Tailwind theme value object into NormalizedToken entries.
|
|
140
|
+
*/
|
|
141
|
+
function flattenThemeValue(
|
|
142
|
+
obj: Record<string, unknown>,
|
|
143
|
+
prefix: string,
|
|
144
|
+
category: TokenCategory,
|
|
145
|
+
source: string,
|
|
146
|
+
theme: string,
|
|
147
|
+
out: NormalizedToken[]
|
|
148
|
+
): void {
|
|
149
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
150
|
+
// Build the path, collapsing DEFAULT to the parent
|
|
151
|
+
const path = key === "DEFAULT" ? prefix : `${prefix}.${key}`;
|
|
152
|
+
|
|
153
|
+
if (value == null || typeof value === "function") {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// String value → leaf token
|
|
158
|
+
if (typeof value === "string") {
|
|
159
|
+
out.push({
|
|
160
|
+
name: path,
|
|
161
|
+
value,
|
|
162
|
+
category,
|
|
163
|
+
source,
|
|
164
|
+
originalName: path,
|
|
165
|
+
theme,
|
|
166
|
+
});
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Number value → convert to string
|
|
171
|
+
if (typeof value === "number") {
|
|
172
|
+
out.push({
|
|
173
|
+
name: path,
|
|
174
|
+
value: String(value),
|
|
175
|
+
category,
|
|
176
|
+
source,
|
|
177
|
+
originalName: path,
|
|
178
|
+
theme,
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Array value → handle fontFamily (join) and fontSize tuples (first element)
|
|
184
|
+
if (Array.isArray(value)) {
|
|
185
|
+
const resolved = resolveArrayValue(value, category);
|
|
186
|
+
if (resolved !== null) {
|
|
187
|
+
out.push({
|
|
188
|
+
name: path,
|
|
189
|
+
value: resolved,
|
|
190
|
+
category,
|
|
191
|
+
source,
|
|
192
|
+
originalName: path,
|
|
193
|
+
theme,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Nested object → recurse
|
|
200
|
+
if (typeof value === "object") {
|
|
201
|
+
flattenThemeValue(
|
|
202
|
+
value as Record<string, unknown>,
|
|
203
|
+
path,
|
|
204
|
+
category,
|
|
205
|
+
source,
|
|
206
|
+
theme,
|
|
207
|
+
out
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Resolve an array theme value to a string.
|
|
215
|
+
* - fontFamily: ['Inter', 'sans-serif'] → 'Inter, sans-serif'
|
|
216
|
+
* - fontSize: ['14px', { lineHeight: '20px' }] → '14px'
|
|
217
|
+
*/
|
|
218
|
+
function resolveArrayValue(
|
|
219
|
+
value: unknown[],
|
|
220
|
+
category: TokenCategory
|
|
221
|
+
): string | null {
|
|
222
|
+
if (value.length === 0) return null;
|
|
223
|
+
|
|
224
|
+
// fontFamily arrays: join with comma
|
|
225
|
+
if (category === "typography" && value.every((v) => typeof v === "string")) {
|
|
226
|
+
return value.join(", ");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// fontSize tuples: [size, config] — take the first element
|
|
230
|
+
if (typeof value[0] === "string") {
|
|
231
|
+
return value[0];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Recursively deep-merge two objects. Values in `override` take precedence.
|
|
239
|
+
* Nested plain objects are merged recursively; arrays and primitives are replaced.
|
|
240
|
+
*/
|
|
241
|
+
function deepMerge(
|
|
242
|
+
base: Record<string, unknown>,
|
|
243
|
+
override: Record<string, unknown>,
|
|
244
|
+
): Record<string, unknown> {
|
|
245
|
+
const result = { ...base };
|
|
246
|
+
for (const [key, value] of Object.entries(override)) {
|
|
247
|
+
const existing = result[key];
|
|
248
|
+
if (
|
|
249
|
+
existing &&
|
|
250
|
+
typeof existing === "object" &&
|
|
251
|
+
!Array.isArray(existing) &&
|
|
252
|
+
value &&
|
|
253
|
+
typeof value === "object" &&
|
|
254
|
+
!Array.isArray(value)
|
|
255
|
+
) {
|
|
256
|
+
result[key] = deepMerge(
|
|
257
|
+
existing as Record<string, unknown>,
|
|
258
|
+
value as Record<string, unknown>,
|
|
259
|
+
);
|
|
260
|
+
} else {
|
|
261
|
+
result[key] = value;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Load a Tailwind config file and extract normalized tokens from its theme.
|
|
269
|
+
*
|
|
270
|
+
* Supports tailwind.config.{js,ts,mjs,cjs} via jiti.
|
|
271
|
+
* Reads both `theme` and `theme.extend` (extend values are merged).
|
|
272
|
+
*/
|
|
273
|
+
export async function loadTailwindConfig(
|
|
274
|
+
configPath: string
|
|
275
|
+
): Promise<NormalizedToken[]> {
|
|
276
|
+
const absolutePath = resolve(configPath);
|
|
277
|
+
|
|
278
|
+
if (!existsSync(absolutePath)) {
|
|
279
|
+
throw new Error(`Tailwind config not found: ${absolutePath}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const jiti = createJiti(absolutePath);
|
|
283
|
+
const mod = await jiti.import(absolutePath);
|
|
284
|
+
const config = (mod as { default?: unknown }).default ?? mod;
|
|
285
|
+
|
|
286
|
+
if (!config || typeof config !== "object") {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`Tailwind config at ${absolutePath} does not export a valid object`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const configObj = config as Record<string, unknown>;
|
|
293
|
+
const theme = (configObj.theme ?? {}) as Record<string, unknown>;
|
|
294
|
+
|
|
295
|
+
// Merge base theme with extend overrides
|
|
296
|
+
const extend = (theme.extend ?? {}) as Record<string, unknown>;
|
|
297
|
+
const merged: Record<string, unknown> = {};
|
|
298
|
+
|
|
299
|
+
// Copy base theme keys (except 'extend')
|
|
300
|
+
for (const [key, value] of Object.entries(theme)) {
|
|
301
|
+
if (key !== "extend") {
|
|
302
|
+
merged[key] = value;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Deep merge extend values on top (preserves nested tokens like colors.blue.50)
|
|
307
|
+
for (const [key, value] of Object.entries(extend)) {
|
|
308
|
+
const existing = merged[key];
|
|
309
|
+
if (
|
|
310
|
+
existing &&
|
|
311
|
+
typeof existing === "object" &&
|
|
312
|
+
!Array.isArray(existing) &&
|
|
313
|
+
value &&
|
|
314
|
+
typeof value === "object" &&
|
|
315
|
+
!Array.isArray(value)
|
|
316
|
+
) {
|
|
317
|
+
merged[key] = deepMerge(
|
|
318
|
+
existing as Record<string, unknown>,
|
|
319
|
+
value as Record<string, unknown>,
|
|
320
|
+
);
|
|
321
|
+
} else {
|
|
322
|
+
merged[key] = value;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return normalizeTailwindTheme(merged);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── Tailwind Config Discovery ───────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
const TAILWIND_CONFIG_FILENAMES = [
|
|
332
|
+
"tailwind.config.ts",
|
|
333
|
+
"tailwind.config.js",
|
|
334
|
+
"tailwind.config.mjs",
|
|
335
|
+
"tailwind.config.cjs",
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Find a Tailwind config file in the given directory.
|
|
340
|
+
*/
|
|
341
|
+
export function findTailwindConfig(projectRoot: string): string | null {
|
|
342
|
+
for (const filename of TAILWIND_CONFIG_FILENAMES) {
|
|
343
|
+
const fullPath = resolve(projectRoot, filename);
|
|
344
|
+
if (existsSync(fullPath)) {
|
|
345
|
+
return fullPath;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── NormalizedTokenLookup ───────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Implements the core TokenLookup interface over NormalizedToken[].
|
|
355
|
+
*
|
|
356
|
+
* This is the single adapter that lets the canonical comparison engine
|
|
357
|
+
* consume tokens from any source (CSS vars, Tailwind, DTCG, Figma).
|
|
358
|
+
*/
|
|
359
|
+
export class NormalizedTokenLookup implements TokenLookup {
|
|
360
|
+
private byName = new Map<string, DesignToken>();
|
|
361
|
+
private byValue = new Map<string, string[]>();
|
|
362
|
+
|
|
363
|
+
constructor(tokens: NormalizedToken[]) {
|
|
364
|
+
for (const token of tokens) {
|
|
365
|
+
const dt = normalizedToDesignToken(token);
|
|
366
|
+
this.byName.set(token.name, dt);
|
|
367
|
+
|
|
368
|
+
const normalizedValue = normalizeForLookup(token.value);
|
|
369
|
+
const existing = this.byValue.get(normalizedValue) || [];
|
|
370
|
+
existing.push(token.name);
|
|
371
|
+
this.byValue.set(normalizedValue, existing);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
findByValue(value: string, theme?: string): string[] {
|
|
376
|
+
const normalized = normalizeForLookup(value);
|
|
377
|
+
const names = this.byValue.get(normalized) || [];
|
|
378
|
+
|
|
379
|
+
if (theme && names.length > 0) {
|
|
380
|
+
return names.filter((name) => {
|
|
381
|
+
const token = this.byName.get(name);
|
|
382
|
+
return token?.theme === theme || token?.theme === "default";
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return names;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
getToken(name: string): DesignToken | undefined {
|
|
390
|
+
return this.byName.get(name);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
calculateUsageSummary(
|
|
394
|
+
styleDiffs: Array<{
|
|
395
|
+
property: string;
|
|
396
|
+
figma: string;
|
|
397
|
+
rendered: string;
|
|
398
|
+
match: boolean;
|
|
399
|
+
}>,
|
|
400
|
+
theme = "default"
|
|
401
|
+
): TokenUsageSummary {
|
|
402
|
+
const hardcodedProperties: EnhancedStyleDiffItem[] = [];
|
|
403
|
+
let usingTokens = 0;
|
|
404
|
+
let hardcoded = 0;
|
|
405
|
+
let implicitMatches = 0;
|
|
406
|
+
|
|
407
|
+
for (const diff of styleDiffs) {
|
|
408
|
+
const figmaTokens = this.findByValue(diff.figma, theme);
|
|
409
|
+
const renderedTokens = this.findByValue(diff.rendered, theme);
|
|
410
|
+
|
|
411
|
+
const figmaToken = figmaTokens.length > 0 ? figmaTokens[0] : undefined;
|
|
412
|
+
const renderedToken =
|
|
413
|
+
renderedTokens.length > 0 ? renderedTokens[0] : undefined;
|
|
414
|
+
const isHardcoded = !!figmaToken && !renderedToken;
|
|
415
|
+
|
|
416
|
+
if (renderedToken) {
|
|
417
|
+
usingTokens++;
|
|
418
|
+
} else if (isHardcoded) {
|
|
419
|
+
hardcoded++;
|
|
420
|
+
hardcodedProperties.push({
|
|
421
|
+
...diff,
|
|
422
|
+
figmaToken,
|
|
423
|
+
renderedToken,
|
|
424
|
+
isHardcoded: true,
|
|
425
|
+
} as EnhancedStyleDiffItem);
|
|
426
|
+
} else if (diff.match && figmaToken) {
|
|
427
|
+
implicitMatches++;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const totalProperties = styleDiffs.length;
|
|
432
|
+
const compliancePercent =
|
|
433
|
+
totalProperties > 0
|
|
434
|
+
? Math.round(
|
|
435
|
+
((usingTokens + implicitMatches) / totalProperties) * 100
|
|
436
|
+
)
|
|
437
|
+
: 100;
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
totalProperties,
|
|
441
|
+
usingTokens,
|
|
442
|
+
hardcoded,
|
|
443
|
+
implicitMatches,
|
|
444
|
+
compliancePercent,
|
|
445
|
+
hardcodedProperties,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ─── Factory ─────────────────────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Create a TokenLookup from NormalizedToken[].
|
|
454
|
+
* Convenience factory for the most common use case.
|
|
455
|
+
*/
|
|
456
|
+
export function createTokenLookup(tokens: NormalizedToken[]): TokenLookup {
|
|
457
|
+
return new NormalizedTokenLookup(tokens);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Convert a NormalizedToken to a DesignToken for TokenLookup compatibility.
|
|
464
|
+
* Fills source-specific fields with sensible defaults.
|
|
465
|
+
*/
|
|
466
|
+
function normalizedToDesignToken(token: NormalizedToken): DesignToken {
|
|
467
|
+
return {
|
|
468
|
+
name: token.name,
|
|
469
|
+
rawValue: token.value,
|
|
470
|
+
resolvedValue: token.value,
|
|
471
|
+
category: token.category,
|
|
472
|
+
level: 2,
|
|
473
|
+
referenceChain: [],
|
|
474
|
+
sourceFile: token.source,
|
|
475
|
+
theme: token.theme,
|
|
476
|
+
selector: token.source === "css-var" ? ":root" : "",
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Normalize a value for consistent reverse lookup.
|
|
482
|
+
* Matches the normalization logic in TokenRegistryManager for parity.
|
|
483
|
+
*/
|
|
484
|
+
function normalizeForLookup(value: string): string {
|
|
485
|
+
let normalized = value.toLowerCase().trim();
|
|
486
|
+
|
|
487
|
+
// Normalize color formats to lowercase hex
|
|
488
|
+
if (looksLikeColor(normalized)) {
|
|
489
|
+
const rgb = parseColor(value);
|
|
490
|
+
if (rgb) {
|
|
491
|
+
normalized = `#${[rgb.r, rgb.g, rgb.b]
|
|
492
|
+
.map((x) => x.toString(16).padStart(2, "0"))
|
|
493
|
+
.join("")}`;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
normalized = normalized.replace(/\s+/g, " ");
|
|
498
|
+
return normalized;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Check if a value looks like a color.
|
|
503
|
+
*/
|
|
504
|
+
function looksLikeColor(value: string): boolean {
|
|
505
|
+
return (
|
|
506
|
+
value.startsWith("#") ||
|
|
507
|
+
value.startsWith("rgb") ||
|
|
508
|
+
value.startsWith("hsl")
|
|
509
|
+
);
|
|
510
|
+
}
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* - Reference resolution (var(--other-token))
|
|
8
8
|
* - Theme detection via selectors
|
|
9
9
|
* - Category inference from naming conventions
|
|
10
|
+
* - Tailwind v4 @theme blocks (via tailwind-v4-parser)
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { readFile } from "node:fs/promises";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
13
15
|
import { resolve, relative } from "node:path";
|
|
14
16
|
import fastGlob from "fast-glob";
|
|
15
17
|
import type {
|
|
@@ -19,6 +21,7 @@ import type {
|
|
|
19
21
|
TokenParseResult,
|
|
20
22
|
TokenParseError,
|
|
21
23
|
} from "../core/index.js";
|
|
24
|
+
import { parseTailwindV4Theme } from "./tailwind-v4-parser.js";
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* Pattern to match CSS custom property declarations
|
|
@@ -78,6 +81,16 @@ export async function parseTokenFile(
|
|
|
78
81
|
? relative(projectRoot, filePath)
|
|
79
82
|
: filePath;
|
|
80
83
|
|
|
84
|
+
// If the file contains @theme blocks, also extract tokens via the v4 parser
|
|
85
|
+
if (containsTailwindV4Theme(content)) {
|
|
86
|
+
const v4Result = parseTailwindV4Theme(content, relativePath);
|
|
87
|
+
tokens.push(...v4Result.tokens);
|
|
88
|
+
warnings.push(...v4Result.warnings);
|
|
89
|
+
for (const err of v4Result.errors) {
|
|
90
|
+
errors.push({ message: err, file: filePath });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
81
94
|
// Track which tokens we've seen for reference resolution
|
|
82
95
|
const tokensByName = new Map<string, { rawValue: string; line?: number }>();
|
|
83
96
|
|
|
@@ -502,3 +515,46 @@ export function normalizeColor(color: string): string {
|
|
|
502
515
|
|
|
503
516
|
return color.toLowerCase();
|
|
504
517
|
}
|
|
518
|
+
|
|
519
|
+
// ─── Tailwind v4 @theme Parser Integration ──────────────────────────────────
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Parse a CSS file containing Tailwind v4 @theme blocks.
|
|
523
|
+
*
|
|
524
|
+
* Reads the file synchronously and delegates to parseTailwindV4Theme.
|
|
525
|
+
* Returns a TokenParseResult compatible with the rest of the token pipeline.
|
|
526
|
+
*/
|
|
527
|
+
export function parseTailwindV4File(filePath: string): TokenParseResult {
|
|
528
|
+
const startTime = performance.now();
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
const content = readFileSync(filePath, "utf-8");
|
|
532
|
+
const result = parseTailwindV4Theme(content, filePath);
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
tokens: result.tokens,
|
|
536
|
+
errors: result.errors.map((message) => ({ message, file: filePath })),
|
|
537
|
+
warnings: result.warnings,
|
|
538
|
+
parseTimeMs: result.parseTimeMs,
|
|
539
|
+
};
|
|
540
|
+
} catch (error) {
|
|
541
|
+
return {
|
|
542
|
+
tokens: [],
|
|
543
|
+
errors: [
|
|
544
|
+
{
|
|
545
|
+
message: error instanceof Error ? error.message : "Unknown error",
|
|
546
|
+
file: filePath,
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
warnings: [],
|
|
550
|
+
parseTimeMs: performance.now() - startTime,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Check whether a CSS file contains Tailwind v4 @theme blocks.
|
|
557
|
+
*/
|
|
558
|
+
export function containsTailwindV4Theme(content: string): boolean {
|
|
559
|
+
return /^@theme\b/m.test(content);
|
|
560
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import pc from 'picocolors';
|
|
2
2
|
import { BRAND } from './core/index.js';
|
|
3
3
|
import { loadConfig, discoverFragmentFiles } from './core/node.js';
|
|
4
|
-
import { buildFragments } from '
|
|
4
|
+
import { buildFragments, getFragmentsJsonStatus } from '@fragments-sdk/compiler';
|
|
5
5
|
import { scan } from './commands/scan.js';
|
|
6
6
|
import {
|
|
7
7
|
detectStorybookConfig,
|
|
@@ -31,41 +31,6 @@ interface FragmentInfo {
|
|
|
31
31
|
hasFigma: boolean;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/**
|
|
35
|
-
* Check if fragments.json exists and is newer than all fragment files.
|
|
36
|
-
*/
|
|
37
|
-
async function isFragmentsJsonStale(configDir: string, outFile: string): Promise<{ stale: boolean; missing: boolean }> {
|
|
38
|
-
const fs = await import('node:fs/promises');
|
|
39
|
-
const path = await import('node:path');
|
|
40
|
-
const fg = await import('fast-glob');
|
|
41
|
-
|
|
42
|
-
const fragmentsJsonPath = path.join(configDir, outFile);
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
const fragmentsJsonStat = await fs.stat(fragmentsJsonPath);
|
|
46
|
-
|
|
47
|
-
// Find all fragment files
|
|
48
|
-
const fragmentFiles = await fg.default(`**/*${BRAND.fileExtension}`, {
|
|
49
|
-
cwd: configDir,
|
|
50
|
-
ignore: ['**/node_modules/**'],
|
|
51
|
-
absolute: true,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
// Check if any fragment file is newer than fragments.json
|
|
55
|
-
for (const file of fragmentFiles) {
|
|
56
|
-
const stat = await fs.stat(file);
|
|
57
|
-
if (stat.mtimeMs > fragmentsJsonStat.mtimeMs) {
|
|
58
|
-
return { stale: true, missing: false };
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return { stale: false, missing: false };
|
|
63
|
-
} catch {
|
|
64
|
-
// fragments.json doesn't exist
|
|
65
|
-
return { stale: false, missing: true };
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
34
|
/**
|
|
70
35
|
* Load fragment files and check which ones have Figma links.
|
|
71
36
|
*/
|
|
@@ -200,11 +165,17 @@ export async function runSetup(options: SetupOptions = {}): Promise<SetupResult>
|
|
|
200
165
|
// Step 2: Build fragments.json if needed (only when fragment files exist)
|
|
201
166
|
if (fragmentFiles.length > 0 && !options.skipBuild) {
|
|
202
167
|
const outFile = config.outFile || BRAND.outFile;
|
|
203
|
-
const
|
|
168
|
+
const status = await getFragmentsJsonStatus(config, configDir, {
|
|
169
|
+
output: outFile,
|
|
170
|
+
configPath: options.configPath,
|
|
171
|
+
});
|
|
204
172
|
|
|
205
|
-
if (missing || stale) {
|
|
206
|
-
const reason = missing ? 'Building' : 'Rebuilding';
|
|
173
|
+
if (status.missing || status.stale) {
|
|
174
|
+
const reason = status.missing ? 'Building' : 'Rebuilding';
|
|
207
175
|
log(pc.dim(`\n${reason} ${BRAND.outFile}...`));
|
|
176
|
+
if (status.reason) {
|
|
177
|
+
log(pc.dim(` Reason: ${status.reason}`));
|
|
178
|
+
}
|
|
208
179
|
|
|
209
180
|
try {
|
|
210
181
|
const buildResult = await buildFragments(config, configDir);
|
package/src/shared/index.ts
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract specific fields from an object using dot notation paths.
|
|
3
|
+
* E.g., projectFields(obj, ['meta.name', 'usage.when']) returns { meta: { name: ... }, usage: { when: ... } }
|
|
4
|
+
*
|
|
5
|
+
* @param obj - The source object to extract fields from
|
|
6
|
+
* @param fields - Array of field paths (supports dot notation for nested fields)
|
|
7
|
+
* @returns A new object containing only the requested fields
|
|
8
|
+
*/
|
|
9
|
+
export function projectFields<T extends Record<string, unknown>>(
|
|
10
|
+
obj: T,
|
|
11
|
+
fields: string[],
|
|
12
|
+
): Partial<T> {
|
|
13
|
+
if (!fields || fields.length === 0) return obj;
|
|
14
|
+
|
|
15
|
+
const result: Record<string, unknown> = {};
|
|
16
|
+
|
|
17
|
+
for (const field of fields) {
|
|
18
|
+
const parts = field.split('.');
|
|
19
|
+
let source: unknown = obj;
|
|
20
|
+
let target = result;
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < parts.length; i++) {
|
|
23
|
+
const part = parts[i];
|
|
24
|
+
const isLast = i === parts.length - 1;
|
|
25
|
+
|
|
26
|
+
if (source === null || source === undefined || typeof source !== 'object') {
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sourceObj = source as Record<string, unknown>;
|
|
31
|
+
const value = sourceObj[part];
|
|
32
|
+
|
|
33
|
+
if (isLast) {
|
|
34
|
+
target[part] = value;
|
|
35
|
+
} else {
|
|
36
|
+
if (!(part in target)) {
|
|
37
|
+
target[part] = {};
|
|
38
|
+
}
|
|
39
|
+
target = target[part] as Record<string, unknown>;
|
|
40
|
+
source = value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result as Partial<T>;
|
|
46
|
+
}
|