@fragments-sdk/cli 0.7.1 → 0.7.3
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/LICENSE +77 -14
- package/dist/bin.js +22 -18
- package/dist/bin.js.map +1 -1
- package/dist/chunk-D34Q6A7S.js +266 -0
- package/dist/chunk-D34Q6A7S.js.map +1 -0
- package/dist/chunk-EKLMXTWU.js +80 -0
- package/dist/chunk-EKLMXTWU.js.map +1 -0
- package/dist/{chunk-GHYYFAQN.js → chunk-P33AKQJW.js} +1 -76
- package/dist/chunk-P33AKQJW.js.map +1 -0
- package/dist/{chunk-U6VTHBNI.js → chunk-QPY4DUFB.js} +177 -46
- package/dist/chunk-QPY4DUFB.js.map +1 -0
- package/dist/{chunk-32VIEOQY.js → chunk-R2YH7NLN.js} +9 -7
- package/dist/{chunk-32VIEOQY.js.map → chunk-R2YH7NLN.js.map} +1 -1
- package/dist/{chunk-5ITIP3ES.js → chunk-R6IZZSE7.js} +44 -278
- package/dist/chunk-R6IZZSE7.js.map +1 -0
- package/dist/{chunk-DQHWLAUV.js → chunk-TOIE7VXF.js} +2 -2
- package/dist/{chunk-GCZMFLDI.js → chunk-UXLGIGSX.js} +60 -3
- package/dist/chunk-UXLGIGSX.js.map +1 -0
- package/dist/{chunk-GKX2HPZ6.js → chunk-YMPGYEWK.js} +9 -3
- package/dist/chunk-YMPGYEWK.js.map +1 -0
- package/dist/chunk-Z7EY4VHE.js +50 -0
- package/dist/{core-SFHPYR5H.js → core-3NMNCLFW.js} +8 -5
- package/dist/discovery-AKGA6CJD.js +28 -0
- package/dist/{generate-54GJAWUY.js → generate-JAUEHKK7.js} +7 -4
- package/dist/{generate-54GJAWUY.js.map → generate-JAUEHKK7.js.map} +1 -1
- package/dist/index.js +15 -11
- package/dist/index.js.map +1 -1
- package/dist/{init-EIM5WNMP.js → init-DZQOT54X.js} +6 -4
- package/dist/{init-EIM5WNMP.js.map → init-DZQOT54X.js.map} +1 -1
- package/dist/mcp-bin.js +5 -3
- package/dist/mcp-bin.js.map +1 -1
- package/dist/sass.node-4XJK6YBF.js +130708 -0
- package/dist/sass.node-4XJK6YBF.js.map +1 -0
- package/dist/scan-OJRCVKK2.js +15 -0
- package/dist/{service-ED2LNCTU.js → service-CFFBHW4X.js} +6 -4
- package/dist/service-CFFBHW4X.js.map +1 -0
- package/dist/{static-viewer-Q4F4QP5M.js → static-viewer-VA2JXSCX.js} +6 -4
- package/dist/static-viewer-VA2JXSCX.js.map +1 -0
- package/dist/{test-6VN2DA3S.js → test-O7DZNKDC.js} +8 -4
- package/dist/{test-6VN2DA3S.js.map → test-O7DZNKDC.js.map} +1 -1
- package/dist/{tokens-P2B7ZAM3.js → tokens-N7THFD6J.js} +10 -7
- package/dist/{tokens-P2B7ZAM3.js.map → tokens-N7THFD6J.js.map} +1 -1
- package/dist/{viewer-GM7IQPPB.js → viewer-QTR7QJMM.js} +390 -25
- package/dist/viewer-QTR7QJMM.js.map +1 -0
- package/package.json +13 -2
- package/src/build.ts +60 -6
- package/src/commands/graph.ts +2 -2
- package/src/core/__tests__/token-resolver.test.ts +82 -0
- package/src/core/loader.ts +0 -3
- package/src/core/parser.ts +41 -1
- package/src/core/token-parser.ts +111 -1
- package/src/core/token-resolver.ts +155 -0
- package/src/service/__tests__/patch-generator.test.ts +2 -2
- package/src/service/patch-generator.ts +8 -1
- package/src/viewer/render-utils.ts +141 -0
- package/src/viewer/vite-plugin.ts +381 -23
- package/dist/chunk-5ITIP3ES.js.map +0 -1
- package/dist/chunk-GCZMFLDI.js.map +0 -1
- package/dist/chunk-GHYYFAQN.js.map +0 -1
- package/dist/chunk-GKX2HPZ6.js.map +0 -1
- package/dist/chunk-U6VTHBNI.js.map +0 -1
- package/dist/scan-KQBKUS64.js +0 -12
- package/dist/viewer-GM7IQPPB.js.map +0 -1
- /package/dist/{chunk-DQHWLAUV.js.map → chunk-TOIE7VXF.js.map} +0 -0
- /package/dist/{core-SFHPYR5H.js.map → chunk-Z7EY4VHE.js.map} +0 -0
- /package/dist/{scan-KQBKUS64.js.map → core-3NMNCLFW.js.map} +0 -0
- /package/dist/{service-ED2LNCTU.js.map → discovery-AKGA6CJD.js.map} +0 -0
- /package/dist/{static-viewer-Q4F4QP5M.js.map → scan-OJRCVKK2.js.map} +0 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { resolveTokensWithSass } from '../token-resolver.js';
|
|
5
|
+
|
|
6
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
7
|
+
|
|
8
|
+
// The actual tokens directory in the workspace
|
|
9
|
+
const TOKENS_DIR = resolve(__dirname, '../../../../../libs/ui/src/tokens');
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// resolveTokensWithSass
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
describe('resolveTokensWithSass', () => {
|
|
16
|
+
it('resolves spacing tokens to CSS values', async () => {
|
|
17
|
+
const resolved = await resolveTokensWithSass(
|
|
18
|
+
[{ name: '--fui-space-4', value: '#{$fui-space-4}' }],
|
|
19
|
+
TOKENS_DIR,
|
|
20
|
+
);
|
|
21
|
+
const value = resolved.get('--fui-space-4');
|
|
22
|
+
expect(value).toBeDefined();
|
|
23
|
+
// Should be a CSS length value (rem, px, or calc expression), not an SCSS interpolation
|
|
24
|
+
expect(value).not.toContain('#{');
|
|
25
|
+
expect(value).not.toContain('$');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('resolves color tokens to hex or rgb values', async () => {
|
|
29
|
+
const resolved = await resolveTokensWithSass(
|
|
30
|
+
[{ name: '--fui-color-accent', value: '#{$fui-color-accent}' }],
|
|
31
|
+
TOKENS_DIR,
|
|
32
|
+
);
|
|
33
|
+
const value = resolved.get('--fui-color-accent');
|
|
34
|
+
expect(value).toBeDefined();
|
|
35
|
+
expect(value).not.toContain('#{');
|
|
36
|
+
expect(value).not.toContain('$');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('resolves multiple tokens in one pass', async () => {
|
|
40
|
+
const tokens = [
|
|
41
|
+
{ name: '--fui-space-1', value: '#{$fui-space-1}' },
|
|
42
|
+
{ name: '--fui-space-2', value: '#{$fui-space-2}' },
|
|
43
|
+
{ name: '--fui-color-accent', value: '#{$fui-color-accent}' },
|
|
44
|
+
{ name: '--fui-bg-primary', value: '#{$fui-bg-primary}' },
|
|
45
|
+
];
|
|
46
|
+
const resolved = await resolveTokensWithSass(tokens, TOKENS_DIR);
|
|
47
|
+
|
|
48
|
+
// All should be resolved
|
|
49
|
+
for (const token of tokens) {
|
|
50
|
+
const value = resolved.get(token.name);
|
|
51
|
+
expect(value, `${token.name} should be resolved`).toBeDefined();
|
|
52
|
+
expect(value, `${token.name} should not contain #{`).not.toContain('#{');
|
|
53
|
+
expect(value, `${token.name} should not contain $`).not.toContain('$');
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns empty map when sass compilation fails (bad path)', async () => {
|
|
58
|
+
const resolved = await resolveTokensWithSass(
|
|
59
|
+
[{ name: '--fui-space-4', value: '#{$fui-space-4}' }],
|
|
60
|
+
'/nonexistent/path/to/tokens',
|
|
61
|
+
);
|
|
62
|
+
expect(resolved.size).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('skips tokens that are already resolved (no unresolved refs)', async () => {
|
|
66
|
+
const tokens = [
|
|
67
|
+
{ name: '--fui-radius-sm', value: '4px' }, // already resolved
|
|
68
|
+
{ name: '--fui-space-4', value: '#{$fui-space-4}' }, // needs resolution
|
|
69
|
+
];
|
|
70
|
+
const resolved = await resolveTokensWithSass(tokens, TOKENS_DIR);
|
|
71
|
+
|
|
72
|
+
// Already-resolved token should not be in the map
|
|
73
|
+
expect(resolved.has('--fui-radius-sm')).toBe(false);
|
|
74
|
+
// Unresolved token should be resolved
|
|
75
|
+
expect(resolved.has('--fui-space-4')).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns empty map for empty token list', async () => {
|
|
79
|
+
const resolved = await resolveTokensWithSass([], TOKENS_DIR);
|
|
80
|
+
expect(resolved.size).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/core/loader.ts
CHANGED
package/src/core/parser.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import ts from "typescript";
|
|
11
|
-
import type { FragmentMeta, FragmentUsage, PropDefinition, AIMetadata } from "./types.js";
|
|
11
|
+
import type { FragmentMeta, FragmentUsage, PropDefinition, AIMetadata, FragmentContract } from "./types.js";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Parsed fragment metadata (extracted statically from AST)
|
|
@@ -48,6 +48,9 @@ export interface ParsedFragmentMetadata {
|
|
|
48
48
|
/** AI-specific metadata for playground context generation */
|
|
49
49
|
ai?: AIMetadata;
|
|
50
50
|
|
|
51
|
+
/** Agent-optimized contract metadata */
|
|
52
|
+
contract?: FragmentContract;
|
|
53
|
+
|
|
51
54
|
/** Parse warnings */
|
|
52
55
|
warnings: string[];
|
|
53
56
|
}
|
|
@@ -135,6 +138,9 @@ export function parseFragmentFile(
|
|
|
135
138
|
// Extract AI metadata
|
|
136
139
|
const ai = extractAIMetadata(arg, warnings);
|
|
137
140
|
|
|
141
|
+
// Extract contract metadata
|
|
142
|
+
const contract = extractContractMetadata(arg);
|
|
143
|
+
|
|
138
144
|
return {
|
|
139
145
|
componentImport,
|
|
140
146
|
componentName,
|
|
@@ -144,6 +150,7 @@ export function parseFragmentFile(
|
|
|
144
150
|
variants,
|
|
145
151
|
relations,
|
|
146
152
|
ai,
|
|
153
|
+
contract,
|
|
147
154
|
warnings,
|
|
148
155
|
};
|
|
149
156
|
}
|
|
@@ -561,6 +568,39 @@ function extractAIMetadata(
|
|
|
561
568
|
return undefined;
|
|
562
569
|
}
|
|
563
570
|
|
|
571
|
+
/**
|
|
572
|
+
* Extract contract metadata from defineFragment call.
|
|
573
|
+
*/
|
|
574
|
+
function extractContractMetadata(
|
|
575
|
+
arg: ts.ObjectLiteralExpression,
|
|
576
|
+
): FragmentContract | undefined {
|
|
577
|
+
const contractProp = findProperty(arg, "contract");
|
|
578
|
+
if (!contractProp || !ts.isObjectLiteralExpression(contractProp)) {
|
|
579
|
+
return undefined;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const contract: FragmentContract = {};
|
|
583
|
+
|
|
584
|
+
// Extract propsSummary array
|
|
585
|
+
const propsSummary = extractStringArray(contractProp, "propsSummary");
|
|
586
|
+
if (propsSummary.length > 0) {
|
|
587
|
+
contract.propsSummary = propsSummary;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Extract a11yRules array
|
|
591
|
+
const a11yRules = extractStringArray(contractProp, "a11yRules");
|
|
592
|
+
if (a11yRules.length > 0) {
|
|
593
|
+
contract.a11yRules = a11yRules;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Only return if we have any fields
|
|
597
|
+
if (Object.keys(contract).length > 0) {
|
|
598
|
+
return contract;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return undefined;
|
|
602
|
+
}
|
|
603
|
+
|
|
564
604
|
/**
|
|
565
605
|
* Extract a string property from an object literal.
|
|
566
606
|
*/
|
package/src/core/token-parser.ts
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
export interface ParsedToken {
|
|
11
11
|
/** Full CSS variable name (e.g., "--fui-color-accent") */
|
|
12
12
|
name: string;
|
|
13
|
+
/** Raw value from the declaration (e.g., "#{$fui-space-4}" or "16px") */
|
|
14
|
+
value?: string;
|
|
15
|
+
/** Resolved value after SCSS variable substitution (e.g., "16px") */
|
|
16
|
+
resolvedValue?: string;
|
|
13
17
|
/** Category inferred from SCSS comment or naming convention */
|
|
14
18
|
category: string;
|
|
15
19
|
/** Description from inline comment, if any */
|
|
@@ -135,12 +139,90 @@ function normalizeCategory(comment: string): string {
|
|
|
135
139
|
return mappings[text] ?? text.replace(/\s+/g, '-');
|
|
136
140
|
}
|
|
137
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Extract SCSS variable declarations ($name: value;) from file content.
|
|
144
|
+
* Returns a map of variable name → value.
|
|
145
|
+
*/
|
|
146
|
+
function extractScssVariables(content: string): Map<string, string> {
|
|
147
|
+
const vars = new Map<string, string>();
|
|
148
|
+
// Match: $var-name: value; (handles multi-word values, stops at semicolon)
|
|
149
|
+
const scssVarRegex = /^\s*(\$[\w-]+)\s*:\s*(.+?)\s*(?:!default\s*)?;/gm;
|
|
150
|
+
|
|
151
|
+
let match: RegExpExecArray | null;
|
|
152
|
+
while ((match = scssVarRegex.exec(content)) !== null) {
|
|
153
|
+
const name = match[1];
|
|
154
|
+
const value = match[2].replace(/\s*\/\/.*$/, '').trim();
|
|
155
|
+
// Only store the first occurrence (canonical definition)
|
|
156
|
+
if (!vars.has(name)) {
|
|
157
|
+
vars.set(name, value);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return vars;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Resolve SCSS interpolations and variable references in a token value.
|
|
166
|
+
*
|
|
167
|
+
* Handles:
|
|
168
|
+
* - `#{$var}` → looks up $var in scssVars map
|
|
169
|
+
* - `$var` standalone → looks up in scssVars map
|
|
170
|
+
* - `var(--other-token, fallback)` → returns fallback if provided
|
|
171
|
+
* - Recursive resolution up to 5 levels deep
|
|
172
|
+
*/
|
|
173
|
+
function resolveTokenValue(
|
|
174
|
+
rawValue: string,
|
|
175
|
+
scssVars: Map<string, string>,
|
|
176
|
+
cssVarValues: Map<string, string>,
|
|
177
|
+
depth = 0
|
|
178
|
+
): string {
|
|
179
|
+
if (depth > 5) return rawValue; // Prevent infinite recursion
|
|
180
|
+
|
|
181
|
+
let resolved = rawValue;
|
|
182
|
+
|
|
183
|
+
// Resolve #{$var} interpolations
|
|
184
|
+
resolved = resolved.replace(/#\{(\$[\w-]+)\}/g, (_, varName) => {
|
|
185
|
+
const val = scssVars.get(varName);
|
|
186
|
+
return val !== undefined
|
|
187
|
+
? resolveTokenValue(val, scssVars, cssVarValues, depth + 1)
|
|
188
|
+
: `#{${varName}}`;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Resolve standalone $var references (not inside #{})
|
|
192
|
+
resolved = resolved.replace(/(?<![#\{])(\$[\w-]+)/g, (_, varName) => {
|
|
193
|
+
const val = scssVars.get(varName);
|
|
194
|
+
return val !== undefined
|
|
195
|
+
? resolveTokenValue(val, scssVars, cssVarValues, depth + 1)
|
|
196
|
+
: varName;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Resolve var(--token, fallback) — use the referenced token value or fallback
|
|
200
|
+
resolved = resolved.replace(
|
|
201
|
+
/var\((--[\w-]+)(?:\s*,\s*(.+?))?\)/g,
|
|
202
|
+
(original, tokenName, fallback) => {
|
|
203
|
+
const tokenVal = cssVarValues.get(tokenName);
|
|
204
|
+
if (tokenVal !== undefined) {
|
|
205
|
+
return resolveTokenValue(tokenVal, scssVars, cssVarValues, depth + 1);
|
|
206
|
+
}
|
|
207
|
+
if (fallback) {
|
|
208
|
+
return resolveTokenValue(fallback.trim(), scssVars, cssVarValues, depth + 1);
|
|
209
|
+
}
|
|
210
|
+
return original;
|
|
211
|
+
}
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
return resolved;
|
|
215
|
+
}
|
|
216
|
+
|
|
138
217
|
/**
|
|
139
218
|
* Parse a SCSS or CSS file and extract CSS custom property declarations.
|
|
140
219
|
*
|
|
141
220
|
* Handles two grouping strategies:
|
|
142
221
|
* 1. Comment-based: Uses `// Category` comments above groups of declarations
|
|
143
222
|
* 2. Naming-based: Falls back to inferring category from variable name patterns
|
|
223
|
+
*
|
|
224
|
+
* Also resolves SCSS variable interpolations (e.g., `#{$fui-space-4}` → `16px`)
|
|
225
|
+
* when the SCSS variable definitions are found in the same file content.
|
|
144
226
|
*/
|
|
145
227
|
export function parseTokenFile(content: string, filePath: string): TokenParseOutput {
|
|
146
228
|
const lines = content.split('\n');
|
|
@@ -149,9 +231,13 @@ export function parseTokenFile(content: string, filePath: string): TokenParseOut
|
|
|
149
231
|
let currentCategory = 'other';
|
|
150
232
|
let hasCommentCategories = false;
|
|
151
233
|
|
|
234
|
+
// First pass: extract SCSS variable declarations for resolution
|
|
235
|
+
const scssVars = extractScssVariables(content);
|
|
236
|
+
|
|
152
237
|
// Regex for CSS custom property declarations
|
|
153
238
|
// Matches: --name: value; (with optional SCSS interpolation)
|
|
154
|
-
|
|
239
|
+
// Captures both the variable name and its value
|
|
240
|
+
const varDeclRegex = /^\s*(--[\w-]+)\s*:\s*(.+?)\s*;/;
|
|
155
241
|
// Regex for section comments (// Category or /* Category */)
|
|
156
242
|
// Allow any characters after uppercase start (including / for "Hero/Marketing")
|
|
157
243
|
const sectionCommentRegex = /^\s*\/\/\s+([A-Z].+)$/;
|
|
@@ -172,6 +258,7 @@ export function parseTokenFile(content: string, filePath: string): TokenParseOut
|
|
|
172
258
|
const varMatch = line.match(varDeclRegex);
|
|
173
259
|
if (varMatch) {
|
|
174
260
|
const name = varMatch[1];
|
|
261
|
+
const rawValue = varMatch[2];
|
|
175
262
|
|
|
176
263
|
// Deduplicate: keep only the first occurrence of each variable.
|
|
177
264
|
// Dark mode and high contrast blocks redefine the same variables
|
|
@@ -183,14 +270,37 @@ export function parseTokenFile(content: string, filePath: string): TokenParseOut
|
|
|
183
270
|
const inlineComment = line.match(/\/\/\s*(.+)$/);
|
|
184
271
|
const description = inlineComment ? inlineComment[1].trim() : undefined;
|
|
185
272
|
|
|
273
|
+
// Clean the value: strip trailing inline comments
|
|
274
|
+
const cleanValue = rawValue.replace(/\s*\/\/.*$/, '').trim();
|
|
275
|
+
|
|
186
276
|
tokens.push({
|
|
187
277
|
name,
|
|
278
|
+
value: cleanValue || undefined,
|
|
188
279
|
category: hasCommentCategories ? currentCategory : inferCategory(name),
|
|
189
280
|
description,
|
|
190
281
|
});
|
|
191
282
|
}
|
|
192
283
|
}
|
|
193
284
|
|
|
285
|
+
// Second pass: build a CSS custom property → raw value map for cross-references
|
|
286
|
+
const cssVarValues = new Map<string, string>();
|
|
287
|
+
for (const token of tokens) {
|
|
288
|
+
if (token.value) {
|
|
289
|
+
cssVarValues.set(token.name, token.value);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Third pass: resolve SCSS interpolations and var() references
|
|
294
|
+
for (const token of tokens) {
|
|
295
|
+
if (token.value) {
|
|
296
|
+
const resolved = resolveTokenValue(token.value, scssVars, cssVarValues);
|
|
297
|
+
// Only set resolvedValue if it's different from raw and doesn't still contain unresolved refs
|
|
298
|
+
if (resolved !== token.value && !resolved.includes('#{') && !resolved.includes('$')) {
|
|
299
|
+
token.resolvedValue = resolved;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
194
304
|
// Group by category
|
|
195
305
|
const categories: Record<string, ParsedToken[]> = {};
|
|
196
306
|
for (const token of tokens) {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Resolver — resolves unresolved SCSS token values using actual sass compilation.
|
|
3
|
+
*
|
|
4
|
+
* The regex-based resolver in token-parser.ts cannot handle SCSS module namespaces,
|
|
5
|
+
* functions (color.scale, math.div), or map lookups. This module provides a fallback
|
|
6
|
+
* that compiles the actual SCSS sources to extract computed CSS custom property values.
|
|
7
|
+
*
|
|
8
|
+
* Used at build time only — no runtime sass dependency in the MCP server.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { resolve, dirname, basename } from 'node:path';
|
|
12
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Round fractional RGB channel values produced by Sass color math.
|
|
16
|
+
*
|
|
17
|
+
* Sass functions like color.scale() and color.adjust() can produce
|
|
18
|
+
* fractional values like rgb(217.8, 217.8, 217.8). This normalizes
|
|
19
|
+
* them to clean integers: rgb(218, 218, 218).
|
|
20
|
+
*
|
|
21
|
+
* Alpha channels in rgba() are preserved as-is (they're legitimately fractional).
|
|
22
|
+
*/
|
|
23
|
+
function roundRgbValues(value: string): string {
|
|
24
|
+
return value
|
|
25
|
+
.replace(
|
|
26
|
+
/rgb\(([^)]+)\)/g,
|
|
27
|
+
(_full, inner: string) => {
|
|
28
|
+
const parts = inner.split(',').map(p => p.trim());
|
|
29
|
+
const rounded = parts.map(p => {
|
|
30
|
+
const num = parseFloat(p);
|
|
31
|
+
return isNaN(num) ? p : String(Math.round(num));
|
|
32
|
+
});
|
|
33
|
+
return `rgb(${rounded.join(', ')})`;
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
.replace(
|
|
37
|
+
/rgba\(([^)]+)\)/g,
|
|
38
|
+
(_full, inner: string) => {
|
|
39
|
+
const parts = inner.split(',').map(p => p.trim());
|
|
40
|
+
// Round RGB channels (first 3), preserve alpha as-is
|
|
41
|
+
const rounded = parts.map((p, i) => {
|
|
42
|
+
if (i >= 3) return p; // alpha channel — don't round
|
|
43
|
+
const num = parseFloat(p);
|
|
44
|
+
return isNaN(num) ? p : String(Math.round(num));
|
|
45
|
+
});
|
|
46
|
+
return `rgba(${rounded.join(', ')})`;
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve unresolved SCSS token values by compiling the project's token SCSS.
|
|
53
|
+
*
|
|
54
|
+
* Generates a temporary SCSS string that imports the project's _variables.scss
|
|
55
|
+
* and includes the fui-css-variables mixin, then compiles it with sass to extract
|
|
56
|
+
* the actual computed values for all CSS custom properties.
|
|
57
|
+
*
|
|
58
|
+
* @param unresolvedTokens - Tokens with values containing #{} or $ that need resolution
|
|
59
|
+
* @param tokensDir - Absolute path to the directory containing _variables.scss
|
|
60
|
+
* @returns Map of token name → resolved CSS value (only for tokens that were unresolved)
|
|
61
|
+
*/
|
|
62
|
+
export async function resolveTokensWithSass(
|
|
63
|
+
unresolvedTokens: Array<{ name: string; value: string }>,
|
|
64
|
+
tokensDir: string,
|
|
65
|
+
): Promise<Map<string, string>> {
|
|
66
|
+
const resolvedMap = new Map<string, string>();
|
|
67
|
+
|
|
68
|
+
// Filter to only tokens that actually need resolution
|
|
69
|
+
const needsResolution = unresolvedTokens.filter(
|
|
70
|
+
t => t.value.includes('#{') || t.value.includes('$'),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (needsResolution.length === 0) {
|
|
74
|
+
return resolvedMap;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// Dynamic import so sass is only loaded when needed (build-time only)
|
|
79
|
+
const sass = await import('sass');
|
|
80
|
+
|
|
81
|
+
// Find the _variables.scss file and determine the mixin name
|
|
82
|
+
const variablesPath = findVariablesFile(tokensDir);
|
|
83
|
+
if (!variablesPath) {
|
|
84
|
+
return resolvedMap;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Determine the module name for @use (filename without _ prefix and .scss suffix)
|
|
88
|
+
const fileName = basename(variablesPath);
|
|
89
|
+
const moduleName = fileName.replace(/^_/, '').replace(/\.scss$/, '');
|
|
90
|
+
|
|
91
|
+
// Generate a SCSS string that imports the variables and includes the mixin
|
|
92
|
+
const scssSource = `
|
|
93
|
+
@use '${moduleName}' as vars;
|
|
94
|
+
:root { @include vars.fui-css-variables; }
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
// Compile the SCSS with the tokens directory as a load path
|
|
98
|
+
const compiled = sass.compileString(scssSource, {
|
|
99
|
+
loadPaths: [tokensDir, dirname(tokensDir)],
|
|
100
|
+
style: 'expanded',
|
|
101
|
+
// Suppress sass deprecation warnings during build
|
|
102
|
+
logger: { warn() {}, debug() {} } as never,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Parse the compiled CSS for --fui-* custom property declarations
|
|
106
|
+
const cssVarRegex = /(--[\w-]+)\s*:\s*([^;]+)/g;
|
|
107
|
+
let match: RegExpExecArray | null;
|
|
108
|
+
const allResolved = new Map<string, string>();
|
|
109
|
+
|
|
110
|
+
while ((match = cssVarRegex.exec(compiled.css)) !== null) {
|
|
111
|
+
allResolved.set(match[1], roundRgbValues(match[2].trim()));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Only return values for the tokens that were originally unresolved
|
|
115
|
+
for (const token of needsResolution) {
|
|
116
|
+
const value = allResolved.get(token.name);
|
|
117
|
+
if (value !== undefined) {
|
|
118
|
+
resolvedMap.set(token.name, value);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// Sass compilation failure is non-fatal — fall back to regex-resolved values
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return resolvedMap;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Find the _variables.scss file in the tokens directory.
|
|
130
|
+
*/
|
|
131
|
+
function findVariablesFile(tokensDir: string): string | null {
|
|
132
|
+
const candidates = ['_variables.scss', 'variables.scss'];
|
|
133
|
+
for (const name of candidates) {
|
|
134
|
+
const path = resolve(tokensDir, name);
|
|
135
|
+
if (existsSync(path)) {
|
|
136
|
+
return path;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Fallback: look for any SCSS file containing 'css-variables' mixin
|
|
141
|
+
try {
|
|
142
|
+
const files = readdirSync(tokensDir).filter(f => f.endsWith('.scss'));
|
|
143
|
+
for (const file of files) {
|
|
144
|
+
const path = resolve(tokensDir, file);
|
|
145
|
+
// We just need to find it exists; the @use import will handle the rest
|
|
146
|
+
if (file.includes('variables') || file.includes('tokens')) {
|
|
147
|
+
return path;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// Directory read failure
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
@@ -72,8 +72,8 @@ describe("patch-generator", () => {
|
|
|
72
72
|
|
|
73
73
|
const result = generateTokenPatches("Button", styleDiffs, registry);
|
|
74
74
|
|
|
75
|
-
// All values are already tokens
|
|
76
|
-
expect(result.summary).toContain("
|
|
75
|
+
// All values are already tokens — message confirms compliant status
|
|
76
|
+
expect(result.summary).toContain("token-compliant");
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
it("identifies unfixable values without matching tokens", () => {
|
|
@@ -85,9 +85,16 @@ export function generateTokenPatches(
|
|
|
85
85
|
const summary = registry.calculateUsageSummary(styleDiffs, theme);
|
|
86
86
|
|
|
87
87
|
if (summary.hardcodedProperties.length === 0) {
|
|
88
|
+
// No hardcoded values → either fully compliant or no styles were analyzed
|
|
89
|
+
let message: string;
|
|
90
|
+
if (summary.totalProperties === 0) {
|
|
91
|
+
message = `No style properties analyzed for ${componentName}. Ensure the component renders visible styles.`;
|
|
92
|
+
} else {
|
|
93
|
+
message = `${componentName} is fully token-compliant — no hardcoded values found.`;
|
|
94
|
+
}
|
|
88
95
|
return {
|
|
89
96
|
patches: [],
|
|
90
|
-
summary:
|
|
97
|
+
summary: message,
|
|
91
98
|
fixableCount: 0,
|
|
92
99
|
unfixableCount: 0,
|
|
93
100
|
};
|
|
@@ -8,6 +8,8 @@ export interface RenderRequest {
|
|
|
8
8
|
component: string;
|
|
9
9
|
/** Props to pass to the component */
|
|
10
10
|
props?: Record<string, unknown>;
|
|
11
|
+
/** Variant name to render (uses variant's render function) */
|
|
12
|
+
variant?: string;
|
|
11
13
|
/** Viewport dimensions */
|
|
12
14
|
viewport?: {
|
|
13
15
|
width: number;
|
|
@@ -161,6 +163,145 @@ render();
|
|
|
161
163
|
`;
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Generate a render script that renders a specific variant by name.
|
|
168
|
+
* The variant lookup happens in the browser using the fragment's variants array.
|
|
169
|
+
*/
|
|
170
|
+
export function generateVariantRenderScript(
|
|
171
|
+
fragmentPath: string,
|
|
172
|
+
componentName: string,
|
|
173
|
+
variantName: string
|
|
174
|
+
): string {
|
|
175
|
+
const variantNameLower = JSON.stringify(variantName.toLowerCase());
|
|
176
|
+
|
|
177
|
+
return `
|
|
178
|
+
import React from "react";
|
|
179
|
+
import { createRoot } from "react-dom/client";
|
|
180
|
+
|
|
181
|
+
async function render() {
|
|
182
|
+
const root = document.getElementById("render-root");
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const fragmentModule = await import("${fragmentPath}");
|
|
186
|
+
const fragment = fragmentModule.default;
|
|
187
|
+
|
|
188
|
+
if (!fragment || !fragment.variants || fragment.variants.length === 0) {
|
|
189
|
+
throw new Error("Fragment has no variants");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const variant = fragment.variants.find(
|
|
193
|
+
v => v.name.toLowerCase() === ${variantNameLower}
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (!variant) {
|
|
197
|
+
const available = fragment.variants.map(v => v.name).join(", ");
|
|
198
|
+
throw new Error("Variant '" + ${JSON.stringify(variantName)} + "' not found. Available: " + available);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const element = variant.render();
|
|
202
|
+
|
|
203
|
+
const reactRoot = createRoot(root);
|
|
204
|
+
reactRoot.render(element);
|
|
205
|
+
|
|
206
|
+
requestAnimationFrame(() => {
|
|
207
|
+
requestAnimationFrame(() => {
|
|
208
|
+
root.classList.add("ready");
|
|
209
|
+
window.__RENDER_READY__ = true;
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.error("Render error:", error);
|
|
214
|
+
root.innerHTML = \`
|
|
215
|
+
<div class="render-error">
|
|
216
|
+
<strong>Render Error</strong>
|
|
217
|
+
<pre>\${error.message}</pre>
|
|
218
|
+
</div>
|
|
219
|
+
\`;
|
|
220
|
+
root.classList.add("ready");
|
|
221
|
+
window.__RENDER_READY__ = true;
|
|
222
|
+
window.__RENDER_ERROR__ = error.message;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
render();
|
|
227
|
+
`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Generate a render script that also runs axe-core for accessibility auditing.
|
|
232
|
+
* When variantName is provided, renders that specific variant; otherwise renders
|
|
233
|
+
* the component with empty props.
|
|
234
|
+
*/
|
|
235
|
+
export function generateA11yRenderScript(
|
|
236
|
+
fragmentPath: string,
|
|
237
|
+
componentName: string,
|
|
238
|
+
variantName?: string
|
|
239
|
+
): string {
|
|
240
|
+
const variantLookup = variantName
|
|
241
|
+
? `
|
|
242
|
+
const variant = fragment.variants?.find(
|
|
243
|
+
v => v.name.toLowerCase() === ${JSON.stringify(variantName.toLowerCase())}
|
|
244
|
+
);
|
|
245
|
+
if (!variant) {
|
|
246
|
+
throw new Error("Variant '${variantName}' not found");
|
|
247
|
+
}
|
|
248
|
+
element = variant.render();`
|
|
249
|
+
: `
|
|
250
|
+
element = React.createElement(fragment.component, {});`;
|
|
251
|
+
|
|
252
|
+
return `
|
|
253
|
+
import React from "react";
|
|
254
|
+
import { createRoot } from "react-dom/client";
|
|
255
|
+
import axe from "axe-core";
|
|
256
|
+
|
|
257
|
+
async function render() {
|
|
258
|
+
const root = document.getElementById("render-root");
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const fragmentModule = await import("${fragmentPath}");
|
|
262
|
+
const fragment = fragmentModule.default;
|
|
263
|
+
|
|
264
|
+
if (!fragment || !fragment.component) {
|
|
265
|
+
throw new Error("Fragment does not export a component");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let element;
|
|
269
|
+
${variantLookup}
|
|
270
|
+
|
|
271
|
+
const reactRoot = createRoot(root);
|
|
272
|
+
reactRoot.render(element);
|
|
273
|
+
|
|
274
|
+
// Wait for React to flush rendering
|
|
275
|
+
await new Promise(resolve => {
|
|
276
|
+
requestAnimationFrame(() => {
|
|
277
|
+
requestAnimationFrame(resolve);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Additional settle time for CSS/animations
|
|
282
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
283
|
+
|
|
284
|
+
// Run axe-core accessibility audit
|
|
285
|
+
const results = await axe.run('#render-root', {
|
|
286
|
+
runOnly: {
|
|
287
|
+
type: 'tag',
|
|
288
|
+
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'],
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
window.__AXE_RESULTS__ = results;
|
|
293
|
+
window.__RENDER_READY__ = true;
|
|
294
|
+
} catch (error) {
|
|
295
|
+
console.error("A11y audit error:", error);
|
|
296
|
+
window.__AXE_ERROR__ = error.message;
|
|
297
|
+
window.__RENDER_READY__ = true;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
render();
|
|
302
|
+
`;
|
|
303
|
+
}
|
|
304
|
+
|
|
164
305
|
/**
|
|
165
306
|
* Generate a virtual module ID for a render request.
|
|
166
307
|
* This creates a unique ID that Vite can resolve.
|