@fragments-sdk/cli 0.15.0 → 0.15.2
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/dist/{ai-client-I6MDWNYA.js → ai-client-LSLQGOMM.js} +1 -2
- package/dist/bin.js +565 -548
- package/dist/bin.js.map +1 -1
- package/dist/chunk-5JF26E55.js +1255 -0
- package/dist/chunk-5JF26E55.js.map +1 -0
- package/dist/{chunk-XJQ5BIWI.js → chunk-6SQPP47U.js} +30 -314
- package/dist/chunk-6SQPP47U.js.map +1 -0
- package/dist/{chunk-65WSVDV5.js → chunk-HQ6A6DTV.js} +1386 -1097
- 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-CZD3AD4Q.js → chunk-ONUP6Z4W.js} +17 -6
- package/dist/chunk-ONUP6Z4W.js.map +1 -0
- package/dist/{codebase-scanner-VOTPXRYW.js → codebase-scanner-MQHUZC2G.js} +1 -2
- package/dist/{converter-JLINP7CJ.js → converter-7XM3Y6NJ.js} +1 -2
- package/dist/{converter-JLINP7CJ.js.map → converter-7XM3Y6NJ.js.map} +1 -1
- package/dist/core/index.js +0 -1
- package/dist/create-JVAU3YKN.js +852 -0
- package/dist/create-JVAU3YKN.js.map +1 -0
- package/dist/doctor-BDPMYYE6.js +385 -0
- package/dist/doctor-BDPMYYE6.js.map +1 -0
- package/dist/{generate-A4FP5426.js → generate-PVOLUAAC.js} +3 -4
- package/dist/{generate-A4FP5426.js.map → generate-PVOLUAAC.js.map} +1 -1
- package/dist/{govern-scan-UCBZR6D6.js → govern-scan-OYFZYOQW.js} +142 -9
- package/dist/govern-scan-OYFZYOQW.js.map +1 -0
- package/dist/index.d.ts +2 -22
- package/dist/index.js +8 -7
- package/dist/index.js.map +1 -1
- package/dist/{init-HGSM35XA.js → init-SSGUSP7Z.js} +3 -4
- package/dist/{init-HGSM35XA.js.map → init-SSGUSP7Z.js.map} +1 -1
- package/dist/{init-cloud-MQ6GRJAZ.js → init-cloud-3DNKPWFB.js} +29 -4
- package/dist/{init-cloud-MQ6GRJAZ.js.map → init-cloud-3DNKPWFB.js.map} +1 -1
- package/dist/mcp-bin.js +1 -2
- 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-VNNKACG2.js → scan-PKSYSTRR.js} +5 -5
- package/dist/{scan-generate-TWRHNU5M.js → scan-generate-VY27PIOX.js} +8 -9
- package/dist/scan-generate-VY27PIOX.js.map +1 -0
- package/dist/{scanner-7LAZYPWZ.js → scanner-4KZNOXAK.js} +1 -2
- package/dist/{service-FHQU7YS7.js → service-QJGWUIVL.js} +16 -9
- package/dist/{snapshot-KQEQ6XHL.js → snapshot-WIJMEIFT.js} +1 -2
- package/dist/{snapshot-KQEQ6XHL.js.map → snapshot-WIJMEIFT.js.map} +1 -1
- package/dist/{static-viewer-63PG6FWY.js → static-viewer-7QIBQZRC.js} +1 -2
- package/dist/{test-UQYUCZIS.js → test-64Z5BKBA.js} +2 -3
- package/dist/{test-UQYUCZIS.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-6GYKDV6U.js → tokens-NZWFQIAB.js} +7 -7
- package/dist/{tokens-generate-VTZV5EEW.js → tokens-generate-5JQSJ27E.js} +1 -2
- package/dist/{tokens-generate-VTZV5EEW.js.map → tokens-generate-5JQSJ27E.js.map} +1 -1
- package/dist/tokens-push-HY3KO36V.js +148 -0
- package/dist/tokens-push-HY3KO36V.js.map +1 -0
- package/package.json +18 -16
- package/src/bin.ts +94 -1
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/label.contract.json +1 -1
- package/src/commands/__fixtures__/shadcn-label-wrapper/src/components/ui/primitive.contract.json +1 -1
- 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 +9 -1
- package/src/commands/__tests__/scan-generate.test.ts +1 -1
- package/src/commands/build.ts +54 -1
- package/src/commands/context.ts +1 -1
- package/src/commands/create.ts +590 -0
- package/src/commands/doctor.ts +3 -2
- package/src/commands/govern-scan.ts +187 -8
- package/src/commands/govern.ts +65 -2
- package/src/commands/init-cloud.ts +32 -4
- package/src/commands/push-contracts.ts +112 -0
- package/src/commands/scan-generate.ts +1 -1
- package/src/commands/scan.ts +13 -0
- package/src/commands/sync.ts +2 -2
- package/src/commands/tokens-push.ts +199 -0
- package/src/core/__tests__/token-resolver.test.ts +1 -1
- package/src/core/component-extractor.test.ts +1 -1
- package/src/core/drift-verifier.ts +1 -1
- package/src/core/extractor-adapter.ts +1 -1
- package/src/index.ts +3 -3
- package/src/migrate/fragment-to-contract.ts +2 -2
- package/src/service/index.ts +8 -0
- package/src/service/tailwind-v4-parser.ts +314 -0
- package/src/service/token-parser.ts +56 -0
- package/src/setup.ts +10 -39
- package/src/theme/__tests__/component-contrast.test.ts +2 -2
- package/src/theme/__tests__/serializer.test.ts +1 -1
- package/src/theme/generator.ts +30 -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/dist/chunk-65WSVDV5.js.map +0 -1
- package/dist/chunk-7WHVW72L.js +0 -2664
- package/dist/chunk-7WHVW72L.js.map +0 -1
- package/dist/chunk-CZD3AD4Q.js.map +0 -1
- package/dist/chunk-MN3TJ3D5.js +0 -695
- package/dist/chunk-MN3TJ3D5.js.map +0 -1
- package/dist/chunk-XJQ5BIWI.js.map +0 -1
- package/dist/chunk-Z7EY4VHE.js +0 -50
- package/dist/govern-scan-UCBZR6D6.js.map +0 -1
- package/dist/sass.node-4XJK6YBF.js +0 -130708
- package/dist/sass.node-4XJK6YBF.js.map +0 -1
- package/dist/scan-generate-TWRHNU5M.js.map +0 -1
- package/src/build.ts +0 -736
- package/src/core/auto-props.ts +0 -464
- package/src/core/component-extractor.ts +0 -1121
- package/src/core/token-resolver.ts +0 -155
- package/src/viewer/preview-adapter.ts +0 -116
- /package/dist/{ai-client-I6MDWNYA.js.map → ai-client-LSLQGOMM.js.map} +0 -0
- /package/dist/{chunk-Z7EY4VHE.js.map → codebase-scanner-MQHUZC2G.js.map} +0 -0
- /package/dist/{codebase-scanner-VOTPXRYW.js.map → node-37AUE74M.js.map} +0 -0
- /package/dist/{scan-VNNKACG2.js.map → scan-PKSYSTRR.js.map} +0 -0
- /package/dist/{scanner-7LAZYPWZ.js.map → scanner-4KZNOXAK.js.map} +0 -0
- /package/dist/{service-FHQU7YS7.js.map → service-QJGWUIVL.js.map} +0 -0
- /package/dist/{static-viewer-63PG6FWY.js.map → static-viewer-7QIBQZRC.js.map} +0 -0
- /package/dist/{tokens-6GYKDV6U.js.map → tokens-NZWFQIAB.js.map} +0 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fragments tokens push — Push code tokens to Fragments Cloud for drift comparison.
|
|
3
|
+
*
|
|
4
|
+
* Extracts CSS custom properties from Tailwind v4 @theme blocks (or config-based
|
|
5
|
+
* token files) and POSTs them to the Fragments Cloud /api/ingest endpoint.
|
|
6
|
+
* Supports --dry-run for local inspection without pushing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import pc from 'picocolors';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Options
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export interface TokensPushOptions {
|
|
17
|
+
/** Path to fragments config file */
|
|
18
|
+
config?: string;
|
|
19
|
+
/** Path to Tailwind v4 CSS file with @theme block */
|
|
20
|
+
tailwindV4?: string;
|
|
21
|
+
/** Parse and display tokens without pushing */
|
|
22
|
+
dryRun?: boolean;
|
|
23
|
+
/** Show detailed output */
|
|
24
|
+
verbose?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// tokensPush
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export async function tokensPush(options: TokensPushOptions): Promise<void> {
|
|
32
|
+
const startTime = Date.now();
|
|
33
|
+
|
|
34
|
+
// 1. Resolve API credentials
|
|
35
|
+
const apiKey = process.env.FRAGMENTS_API_KEY;
|
|
36
|
+
const baseUrl = process.env.FRAGMENTS_URL || 'https://app.usefragments.com';
|
|
37
|
+
|
|
38
|
+
if (!apiKey && !options.dryRun) {
|
|
39
|
+
console.error(pc.red('Error: FRAGMENTS_API_KEY environment variable is required'));
|
|
40
|
+
console.error(pc.dim('Get your API key from https://app.usefragments.com/api-keys'));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. Token extraction
|
|
45
|
+
const flatMap: Record<string, string> = {};
|
|
46
|
+
|
|
47
|
+
if (options.tailwindV4) {
|
|
48
|
+
// Use @theme parser for Tailwind v4 CSS files
|
|
49
|
+
const { parseTailwindV4File } = await import('../service/token-parser.js');
|
|
50
|
+
|
|
51
|
+
const filePath = resolve(process.cwd(), options.tailwindV4);
|
|
52
|
+
const result = parseTailwindV4File(filePath);
|
|
53
|
+
|
|
54
|
+
if (result.errors.length > 0) {
|
|
55
|
+
for (const err of result.errors) {
|
|
56
|
+
console.error(pc.red(` Error: ${err.message}`));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (options.verbose && result.warnings.length > 0) {
|
|
61
|
+
for (const warn of result.warnings) {
|
|
62
|
+
console.warn(pc.yellow(` Warning: ${warn}`));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const token of result.tokens) {
|
|
67
|
+
flatMap[token.name] = token.resolvedValue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (options.verbose) {
|
|
71
|
+
console.log(
|
|
72
|
+
pc.dim(` Parsed ${result.tokens.length} tokens from ${options.tailwindV4} (${result.parseTimeMs.toFixed(1)}ms)`),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
} else if (options.config) {
|
|
76
|
+
// Use config-based token extraction
|
|
77
|
+
try {
|
|
78
|
+
const { loadConfig } = await import('../core/node.js');
|
|
79
|
+
const { parseTokenFiles } = await import('../service/index.js');
|
|
80
|
+
|
|
81
|
+
const { config, configDir } = await loadConfig(options.config);
|
|
82
|
+
if (config.tokens?.include?.length) {
|
|
83
|
+
const result = await parseTokenFiles(config.tokens, configDir);
|
|
84
|
+
for (const token of result.tokens) {
|
|
85
|
+
flatMap[token.name] = token.resolvedValue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (options.verbose) {
|
|
89
|
+
console.log(
|
|
90
|
+
pc.dim(` Parsed ${result.tokens.length} tokens from config (${result.parseTimeMs.toFixed(1)}ms)`),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
console.error(pc.red('Error: Config file has no tokens.include patterns'));
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error(pc.red('Error loading config:'), error instanceof Error ? error.message : error);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
// No source specified — try auto-detection
|
|
103
|
+
try {
|
|
104
|
+
const { loadConfig } = await import('../core/node.js');
|
|
105
|
+
const { parseTokenFiles } = await import('../service/index.js');
|
|
106
|
+
|
|
107
|
+
const { config, configDir } = await loadConfig();
|
|
108
|
+
if (config.tokens?.include?.length) {
|
|
109
|
+
const result = await parseTokenFiles(config.tokens, configDir);
|
|
110
|
+
for (const token of result.tokens) {
|
|
111
|
+
flatMap[token.name] = token.resolvedValue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (options.verbose) {
|
|
115
|
+
console.log(
|
|
116
|
+
pc.dim(` Parsed ${result.tokens.length} tokens from auto-detected config (${result.parseTimeMs.toFixed(1)}ms)`),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// No config found — fall through to error
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (Object.keys(flatMap).length === 0) {
|
|
125
|
+
console.error(pc.red('Error: No token source specified'));
|
|
126
|
+
console.error(pc.dim('Provide one of:'));
|
|
127
|
+
console.error(pc.dim(' --tailwind-v4 <path> Path to Tailwind v4 CSS file with @theme block'));
|
|
128
|
+
console.error(pc.dim(' --config <path> Path to fragments config with tokens.include'));
|
|
129
|
+
console.error(pc.dim('\nExample: fragments tokens push --tailwind-v4 ./app.css'));
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const tokenCount = Object.keys(flatMap).length;
|
|
135
|
+
|
|
136
|
+
if (tokenCount === 0) {
|
|
137
|
+
console.log(pc.yellow('No tokens found.'));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log(pc.cyan(`Found ${tokenCount} tokens`));
|
|
142
|
+
|
|
143
|
+
// 3. Dry run — display tokens and exit
|
|
144
|
+
if (options.dryRun) {
|
|
145
|
+
console.log(pc.dim('\nDry run — tokens that would be pushed:\n'));
|
|
146
|
+
const entries = Object.entries(flatMap);
|
|
147
|
+
for (const [name, value] of entries.slice(0, 20)) {
|
|
148
|
+
console.log(` ${pc.bold(name)}: ${value}`);
|
|
149
|
+
}
|
|
150
|
+
if (entries.length > 20) {
|
|
151
|
+
console.log(pc.dim(` ... and ${entries.length - 20} more`));
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 4. POST to Fragments Cloud
|
|
157
|
+
try {
|
|
158
|
+
const response = await fetch(`${baseUrl}/api/ingest`, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: {
|
|
161
|
+
'Content-Type': 'application/json',
|
|
162
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
codeTokens: JSON.stringify(flatMap),
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (!response.ok) {
|
|
170
|
+
const errorText = await response.text();
|
|
171
|
+
console.error(pc.red(`Error: API returned ${response.status}`));
|
|
172
|
+
console.error(pc.dim(errorText));
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const result = await response.json() as Record<string, unknown>;
|
|
177
|
+
|
|
178
|
+
console.log(pc.green(`\nPushed ${tokenCount} tokens to Fragments Cloud`));
|
|
179
|
+
|
|
180
|
+
if (result.tokenDrift) {
|
|
181
|
+
const drift = result.tokenDrift as Record<string, unknown>;
|
|
182
|
+
const summary = drift.summary as Record<string, number> | undefined;
|
|
183
|
+
if (summary) {
|
|
184
|
+
console.log(pc.cyan('\nDrift Summary:'));
|
|
185
|
+
if (summary.total !== undefined) console.log(pc.dim(` Total issues: ${summary.total}`));
|
|
186
|
+
if (summary.missingInCode > 0) console.log(pc.yellow(` Missing in code: ${summary.missingInCode}`));
|
|
187
|
+
if (summary.missingInFigma > 0) console.log(pc.yellow(` Missing in Figma: ${summary.missingInFigma}`));
|
|
188
|
+
if (summary.valueMismatch > 0) console.log(pc.yellow(` Value mismatches: ${summary.valueMismatch}`));
|
|
189
|
+
if (summary.score !== undefined) console.log(` Health score: ${summary.score}%`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
console.log(pc.dim(`\nView dashboard: ${baseUrl}/tokens`));
|
|
194
|
+
console.log(pc.dim(`Completed in ${Date.now() - startTime}ms`));
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error(pc.red('Error pushing tokens:'), error instanceof Error ? error.message : error);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { resolveTokensWithSass } from '
|
|
4
|
+
import { resolveTokensWithSass } from '@fragments-sdk/extract';
|
|
5
5
|
|
|
6
6
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
7
7
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
2
|
import { resolve, join } from 'node:path';
|
|
3
|
-
import { createComponentExtractor, type ComponentExtractor, type ComponentMeta } from '
|
|
3
|
+
import { createComponentExtractor, type ComponentExtractor, type ComponentMeta } from '@fragments-sdk/extract';
|
|
4
4
|
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
// Setup
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { CompiledContractOutput } from '@fragments-sdk/core';
|
|
9
|
-
import type { ComponentMeta } from '
|
|
9
|
+
import type { ComponentMeta } from '@fragments-sdk/extract';
|
|
10
10
|
|
|
11
11
|
export interface DriftReport {
|
|
12
12
|
componentName: string;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Non-React frameworks return a no-op adapter with verificationLevel: 'none'.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { createComponentExtractor, type ComponentExtractor, type ComponentMeta } from '
|
|
8
|
+
import { createComponentExtractor, type ComponentExtractor, type ComponentMeta } from '@fragments-sdk/extract';
|
|
9
9
|
|
|
10
10
|
export interface ExtractorAdapter {
|
|
11
11
|
/** Whether this adapter can handle the given framework */
|
package/src/index.ts
CHANGED
|
@@ -17,9 +17,9 @@ export type {
|
|
|
17
17
|
ValidationRunOptions,
|
|
18
18
|
} from "./validators.js";
|
|
19
19
|
|
|
20
|
-
// Build
|
|
21
|
-
export { buildFragments } from
|
|
22
|
-
export type { BuildResult } from
|
|
20
|
+
// Build (delegated to @fragments-sdk/compiler)
|
|
21
|
+
export { buildFragments } from '@fragments-sdk/compiler';
|
|
22
|
+
export type { BuildResult } from '@fragments-sdk/compiler';
|
|
23
23
|
|
|
24
24
|
// Screenshot
|
|
25
25
|
export { runScreenshotCommand } from "./screenshot.js";
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
10
10
|
import { resolve, dirname, relative, basename } from 'node:path';
|
|
11
11
|
import { parseFragmentFile } from '../core/parser.js';
|
|
12
|
-
import { createComponentExtractor, type ComponentMeta } from '
|
|
13
|
-
import { resolveComponentSourcePath } from '
|
|
12
|
+
import { createComponentExtractor, type ComponentMeta } from '@fragments-sdk/extract';
|
|
13
|
+
import { resolveComponentSourcePath } from '@fragments-sdk/extract';
|
|
14
14
|
import type { ComponentContract } from '@fragments-sdk/core';
|
|
15
15
|
|
|
16
16
|
export interface MigrateOptions {
|
package/src/service/index.ts
CHANGED
|
@@ -97,12 +97,20 @@ export { generateHtmlReport } from "./report.js";
|
|
|
97
97
|
export {
|
|
98
98
|
parseTokenFile,
|
|
99
99
|
parseTokenFiles,
|
|
100
|
+
parseTailwindV4File,
|
|
101
|
+
containsTailwindV4Theme,
|
|
100
102
|
hexToRgb,
|
|
101
103
|
rgbToHex,
|
|
102
104
|
parseRgb,
|
|
103
105
|
normalizeColor,
|
|
104
106
|
} from "./token-parser.js";
|
|
105
107
|
|
|
108
|
+
// Tailwind v4 @theme parser
|
|
109
|
+
export {
|
|
110
|
+
parseTailwindV4Theme,
|
|
111
|
+
type ThemeParserResult,
|
|
112
|
+
} from "./tailwind-v4-parser.js";
|
|
113
|
+
|
|
106
114
|
export {
|
|
107
115
|
TokenRegistryManager,
|
|
108
116
|
getSharedTokenRegistry,
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tailwind v4 @theme Block Parser
|
|
3
|
+
*
|
|
4
|
+
* Extracts CSS custom properties (--name: value;) from Tailwind v4 @theme {}
|
|
5
|
+
* blocks. Tailwind v4 moves token definitions from tailwind.config.js into
|
|
6
|
+
* CSS @theme blocks, declaring design tokens as standard custom properties.
|
|
7
|
+
*
|
|
8
|
+
* Handles:
|
|
9
|
+
* - Multiple @theme blocks in one file
|
|
10
|
+
* - @theme inline { ... } variant
|
|
11
|
+
* - Empty @theme {} blocks
|
|
12
|
+
* - Comments (// single-line and multi-line)
|
|
13
|
+
* - Nested blocks inside @theme (tracks brace depth correctly)
|
|
14
|
+
* - Lines without semicolons (skipped with warning)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
DesignToken,
|
|
19
|
+
TokenCategory,
|
|
20
|
+
} from "../core/index.js";
|
|
21
|
+
|
|
22
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface ThemeParserResult {
|
|
25
|
+
tokens: DesignToken[];
|
|
26
|
+
errors: string[];
|
|
27
|
+
warnings: string[];
|
|
28
|
+
parseTimeMs: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Category Inference ──────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Category inference patterns — same logic as token-parser.ts CATEGORY_PATTERNS
|
|
35
|
+
* to keep categorization consistent across all parsers.
|
|
36
|
+
*/
|
|
37
|
+
const CATEGORY_PATTERNS: Array<{
|
|
38
|
+
pattern: RegExp;
|
|
39
|
+
category: TokenCategory;
|
|
40
|
+
}> = [
|
|
41
|
+
{ pattern: /color|bg|background|border-color|fill|stroke/i, category: "color" },
|
|
42
|
+
{ pattern: /spacing|margin|padding|gap|space|inset/i, category: "spacing" },
|
|
43
|
+
{ pattern: /font|text|line-height|letter-spacing|typography/i, category: "typography" },
|
|
44
|
+
{ pattern: /radius|rounded|corner/i, category: "radius" },
|
|
45
|
+
{ pattern: /shadow|elevation/i, category: "shadow" },
|
|
46
|
+
{ pattern: /size|width|height|min|max/i, category: "sizing" },
|
|
47
|
+
{ pattern: /border(?!-color)|stroke-width|outline/i, category: "border" },
|
|
48
|
+
{ pattern: /animation|transition|duration|timing|delay/i, category: "animation" },
|
|
49
|
+
{ pattern: /z-index|layer|stack/i, category: "z-index" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Infer token category from a CSS custom property name.
|
|
54
|
+
*/
|
|
55
|
+
function inferCategory(name: string): TokenCategory {
|
|
56
|
+
const lowerName = name.toLowerCase();
|
|
57
|
+
|
|
58
|
+
for (const { pattern, category } of CATEGORY_PATTERNS) {
|
|
59
|
+
if (pattern.test(lowerName)) {
|
|
60
|
+
return category;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return "other";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Infer token level:
|
|
69
|
+
* - 1 (base) for raw values (hex colors, numbers, named CSS values)
|
|
70
|
+
* - 2 (semantic) for var() references
|
|
71
|
+
*/
|
|
72
|
+
function inferLevel(rawValue: string): 1 | 2 | 3 {
|
|
73
|
+
if (/var\(/.test(rawValue)) {
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── Comment Stripping ───────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Remove block comments from a CSS string, handling multi-line spans.
|
|
83
|
+
* Returns the cleaned string.
|
|
84
|
+
*/
|
|
85
|
+
function stripBlockComments(css: string): string {
|
|
86
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Parser ──────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/** Regex to match a CSS custom property declaration inside a @theme block */
|
|
92
|
+
const CUSTOM_PROPERTY_PATTERN = /^\s*(--[\w-]+)\s*:\s*(.+?)\s*;?\s*$/;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse Tailwind v4 @theme blocks from CSS content and extract design tokens.
|
|
96
|
+
*
|
|
97
|
+
* @param cssContent - Raw CSS file content (may contain @theme blocks)
|
|
98
|
+
* @param filePath - Optional source file path for diagnostics
|
|
99
|
+
* @returns ThemeParserResult with extracted tokens, errors, and warnings
|
|
100
|
+
*/
|
|
101
|
+
export function parseTailwindV4Theme(
|
|
102
|
+
cssContent: string,
|
|
103
|
+
filePath?: string,
|
|
104
|
+
): ThemeParserResult {
|
|
105
|
+
const startTime = performance.now();
|
|
106
|
+
const tokens: DesignToken[] = [];
|
|
107
|
+
const errors: string[] = [];
|
|
108
|
+
const warnings: string[] = [];
|
|
109
|
+
const source = filePath ?? "unknown";
|
|
110
|
+
|
|
111
|
+
// Strip block comments first so they don't interfere with parsing
|
|
112
|
+
const cleaned = stripBlockComments(cssContent);
|
|
113
|
+
|
|
114
|
+
const lines = cleaned.split("\n");
|
|
115
|
+
|
|
116
|
+
let insideTheme = false;
|
|
117
|
+
let themeDepth = 0; // brace depth relative to the @theme block
|
|
118
|
+
let pendingTheme = false; // saw @theme keyword, waiting for opening brace
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
121
|
+
let line = lines[i];
|
|
122
|
+
const lineNumber = i + 1;
|
|
123
|
+
|
|
124
|
+
// Strip single-line comments (// ...)
|
|
125
|
+
const singleCommentIdx = line.indexOf("//");
|
|
126
|
+
if (singleCommentIdx !== -1) {
|
|
127
|
+
// Make sure // is not inside a string (simple heuristic: not inside quotes)
|
|
128
|
+
const beforeComment = line.slice(0, singleCommentIdx);
|
|
129
|
+
const singleQuotes = (beforeComment.match(/'/g) || []).length;
|
|
130
|
+
const doubleQuotes = (beforeComment.match(/"/g) || []).length;
|
|
131
|
+
// If both quote counts are even, the // is not inside a string
|
|
132
|
+
if (singleQuotes % 2 === 0 && doubleQuotes % 2 === 0) {
|
|
133
|
+
line = beforeComment;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const trimmed = line.trim();
|
|
138
|
+
|
|
139
|
+
// Skip empty lines
|
|
140
|
+
if (trimmed === "") {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Detect @theme keyword (with optional modifiers like "inline")
|
|
145
|
+
if (!insideTheme && !pendingTheme) {
|
|
146
|
+
// Match @theme, @theme inline, @theme { ... }, etc.
|
|
147
|
+
if (/^@theme\b/.test(trimmed)) {
|
|
148
|
+
pendingTheme = true;
|
|
149
|
+
// Check if the opening brace is on the same line
|
|
150
|
+
const braceIdx = trimmed.indexOf("{");
|
|
151
|
+
if (braceIdx !== -1) {
|
|
152
|
+
pendingTheme = false;
|
|
153
|
+
insideTheme = true;
|
|
154
|
+
themeDepth = 1;
|
|
155
|
+
|
|
156
|
+
// Count any additional braces on this same line
|
|
157
|
+
const afterBrace = trimmed.slice(braceIdx + 1);
|
|
158
|
+
for (const ch of afterBrace) {
|
|
159
|
+
if (ch === "{") themeDepth++;
|
|
160
|
+
else if (ch === "}") themeDepth--;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// If there are declarations on the same line as @theme {
|
|
164
|
+
// (e.g., @theme { --color-primary: #000; })
|
|
165
|
+
const inlineContent = afterBrace.trim();
|
|
166
|
+
if (inlineContent && themeDepth > 0) {
|
|
167
|
+
processThemeLine(inlineContent, lineNumber, source, tokens, warnings);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (themeDepth <= 0) {
|
|
171
|
+
insideTheme = false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// If we saw @theme but haven't found the opening brace yet
|
|
179
|
+
if (pendingTheme) {
|
|
180
|
+
const braceIdx = trimmed.indexOf("{");
|
|
181
|
+
if (braceIdx !== -1) {
|
|
182
|
+
pendingTheme = false;
|
|
183
|
+
insideTheme = true;
|
|
184
|
+
themeDepth = 1;
|
|
185
|
+
|
|
186
|
+
// Count additional braces on this line
|
|
187
|
+
const afterBrace = trimmed.slice(braceIdx + 1);
|
|
188
|
+
for (const ch of afterBrace) {
|
|
189
|
+
if (ch === "{") themeDepth++;
|
|
190
|
+
else if (ch === "}") themeDepth--;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Process any inline content after the brace
|
|
194
|
+
const inlineContent = afterBrace.trim();
|
|
195
|
+
if (inlineContent && themeDepth > 0) {
|
|
196
|
+
processThemeLine(inlineContent, lineNumber, source, tokens, warnings);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (themeDepth <= 0) {
|
|
200
|
+
insideTheme = false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// If no brace found, keep waiting (could be multi-line @theme declaration)
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Inside a @theme block — track braces and extract declarations
|
|
208
|
+
if (insideTheme) {
|
|
209
|
+
// Count braces to track depth
|
|
210
|
+
for (const ch of trimmed) {
|
|
211
|
+
if (ch === "{") {
|
|
212
|
+
themeDepth++;
|
|
213
|
+
} else if (ch === "}") {
|
|
214
|
+
themeDepth--;
|
|
215
|
+
if (themeDepth <= 0) {
|
|
216
|
+
insideTheme = false;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Only extract declarations at depth 1 (directly inside @theme)
|
|
223
|
+
// or if we're still inside the theme block
|
|
224
|
+
if (insideTheme || themeDepth > 0) {
|
|
225
|
+
// Remove any closing braces from the line content before matching
|
|
226
|
+
const contentLine = trimmed.replace(/\}/g, "").trim();
|
|
227
|
+
if (contentLine) {
|
|
228
|
+
processThemeLine(contentLine, lineNumber, source, tokens, warnings);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// If we ended while still expecting a @theme block, warn
|
|
235
|
+
if (pendingTheme) {
|
|
236
|
+
warnings.push(
|
|
237
|
+
`@theme keyword found but no opening brace — incomplete block in ${source}`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (insideTheme) {
|
|
242
|
+
warnings.push(
|
|
243
|
+
`Unclosed @theme block — missing closing brace in ${source}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
tokens,
|
|
249
|
+
errors,
|
|
250
|
+
warnings,
|
|
251
|
+
parseTimeMs: performance.now() - startTime,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Process a single line inside a @theme block, attempting to extract a
|
|
257
|
+
* CSS custom property declaration.
|
|
258
|
+
*/
|
|
259
|
+
function processThemeLine(
|
|
260
|
+
line: string,
|
|
261
|
+
lineNumber: number,
|
|
262
|
+
source: string,
|
|
263
|
+
tokens: DesignToken[],
|
|
264
|
+
warnings: string[],
|
|
265
|
+
): void {
|
|
266
|
+
const match = line.match(CUSTOM_PROPERTY_PATTERN);
|
|
267
|
+
|
|
268
|
+
if (!match) {
|
|
269
|
+
// If the line looks like it has a custom property but is missing a semicolon
|
|
270
|
+
const partialMatch = line.match(/^\s*(--[\w-]+)\s*:\s*(.+?)\s*$/);
|
|
271
|
+
if (partialMatch) {
|
|
272
|
+
// It matched without semicolon — treat as valid (CSS tolerates missing trailing semicolons)
|
|
273
|
+
const [, name, rawValue] = partialMatch;
|
|
274
|
+
tokens.push(buildToken(name, rawValue, lineNumber, source));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Lines that contain -- but couldn't be parsed
|
|
279
|
+
if (/^\s*--/.test(line)) {
|
|
280
|
+
warnings.push(
|
|
281
|
+
`Line ${lineNumber}: Could not parse custom property declaration: ${line.trim()}`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const [, name, rawValue] = match;
|
|
288
|
+
tokens.push(buildToken(name, rawValue, lineNumber, source));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Build a DesignToken from a parsed custom property declaration.
|
|
293
|
+
*/
|
|
294
|
+
function buildToken(
|
|
295
|
+
name: string,
|
|
296
|
+
rawValue: string,
|
|
297
|
+
lineNumber: number,
|
|
298
|
+
source: string,
|
|
299
|
+
): DesignToken {
|
|
300
|
+
const value = rawValue.trim();
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
name,
|
|
304
|
+
rawValue: value,
|
|
305
|
+
resolvedValue: value,
|
|
306
|
+
category: inferCategory(name),
|
|
307
|
+
level: inferLevel(value),
|
|
308
|
+
referenceChain: [],
|
|
309
|
+
sourceFile: source,
|
|
310
|
+
lineNumber,
|
|
311
|
+
theme: "default",
|
|
312
|
+
selector: "@theme",
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -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
|
+
}
|