@ethlete/core 5.0.0-next.6 → 5.0.0-next.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +686 -0
  2. package/README.md +28 -4
  3. package/fesm2022/ethlete-core.mjs +8020 -3527
  4. package/fesm2022/ethlete-core.mjs.map +1 -1
  5. package/generators/devtools-about/generator.js +122 -0
  6. package/generators/devtools-about/schema.json +25 -0
  7. package/generators/generators.json +26 -5
  8. package/generators/migrate-provider-shape/migration-scope.js +35 -0
  9. package/generators/migrate-provider-shape/migration.js +50 -0
  10. package/generators/migrate-provider-shape/provider-shape.js +284 -0
  11. package/generators/migrate-provider-shape/schema.json +26 -0
  12. package/generators/migrate-surface-interaction-swatch/interaction-swatch.js +63 -0
  13. package/generators/migrate-surface-interaction-swatch/migration.js +36 -0
  14. package/generators/migrate-surface-interaction-swatch/schema.json +26 -0
  15. package/generators/migrate-to-v5/color-naming.js +170 -0
  16. package/generators/migrate-to-v5/create-provider.js +14 -14
  17. package/generators/migrate-to-v5/migration.js +11 -1
  18. package/generators/migrate-to-v5/removed-exports.js +93 -0
  19. package/generators/migrate-to-v5/router-state-service.js +33 -8
  20. package/generators/migrate-to-v5/schema.json +10 -0
  21. package/generators/migrate-to-v5/viewport-service.js +41 -60
  22. package/generators/tailwind-4-color-theme/generator.js +608 -0
  23. package/generators/tailwind-4-color-theme/schema.json +44 -0
  24. package/generators/tailwind-4-surface-theme/generator.js +599 -0
  25. package/generators/tailwind-4-surface-theme/schema.json +48 -0
  26. package/migrations/seo-directive-removed.md +80 -0
  27. package/migrations/surface-theme-regenerate.md +20 -0
  28. package/migrations.json +43 -0
  29. package/package.json +25 -14
  30. package/types/ethlete-core.d.ts +3329 -1774
  31. package/generators/tailwind-4-theme/generator.js +0 -490
  32. package/generators/tailwind-4-theme/schema.json +0 -32
@@ -0,0 +1,608 @@
1
+ import { formatFiles, logger, visitNotIgnoredFiles } from '@nx/devkit';
2
+ import { Project, SyntaxKind } from 'ts-morph';
3
+ export default async function generate(tree, schema) {
4
+ logger.log('\nšŸ”„ Starting Tailwind 4 theme generator...\n');
5
+ const themesPath = schema.themesPath || 'src/themes.ts';
6
+ const outputPath = schema.outputPath || 'src/styles/generated-tailwind-themes.css';
7
+ const prefix = schema.prefix || 'et';
8
+ // The runtime prefix drives the theme-swap CSS variables (--<runtimePrefix>-color-primary)
9
+ // and selectors (.<runtimePrefix>-color--<name>). It defaults to `prefix` so existing
10
+ // consumers who only ever passed one prefix see no change in output.
11
+ const runtimePrefix = schema.runtimePrefix || prefix;
12
+ // Step 1: Check if themes file exists
13
+ if (!tree.exists(themesPath)) {
14
+ logger.error(`āŒ Themes file not found at: ${themesPath}`);
15
+ logger.log(`\nPlease specify the correct path using --themesPath option.`);
16
+ logger.log(`Example: nx g @ethlete/core:tailwind-4-color-theme --themesPath=src/app/themes.ts\n`);
17
+ return;
18
+ }
19
+ logger.log(`šŸ“ Reading themes from: ${themesPath}`);
20
+ // Step 2: Read and parse themes file
21
+ const themesContent = tree.read(themesPath, 'utf-8');
22
+ if (!themesContent) {
23
+ logger.error('āŒ Failed to read themes file');
24
+ return;
25
+ }
26
+ // Step 3: Try to extract themes using TypeScript
27
+ let themes;
28
+ try {
29
+ themes = extractThemesFromContent(themesContent, themesPath);
30
+ logger.log(`āœ… Found ${themes.length} theme(s)`);
31
+ }
32
+ catch (error) {
33
+ logger.error('āŒ Failed to parse themes file');
34
+ logger.error(` ${error instanceof Error ? error.message : String(error)}`);
35
+ logger.log('\nThe themes file must export themes as:');
36
+ logger.log(' export const THEMES = [...] satisfies Theme[];\n');
37
+ return;
38
+ }
39
+ // Step 4: Apply the generation-time default override, if any. The shared theme
40
+ // definitions may mark a default via isDefault, but in a monorepo each app picks its
41
+ // own default at its generation invocation - the option wins over the definitions.
42
+ if (schema.defaultTheme) {
43
+ try {
44
+ const defaultName = applyDefaultThemeOverride(themes, schema.defaultTheme);
45
+ logger.log(`šŸŽÆ Default theme set at generation time: ${defaultName}`);
46
+ }
47
+ catch (error) {
48
+ logger.error('āŒ Invalid --defaultTheme option');
49
+ logger.error(` ${error instanceof Error ? error.message : String(error)}`);
50
+ return;
51
+ }
52
+ }
53
+ // Step 5: Validate theme configuration
54
+ try {
55
+ validateThemeConfiguration(themes);
56
+ }
57
+ catch (error) {
58
+ logger.error('āŒ Theme configuration error');
59
+ logger.error(` ${error instanceof Error ? error.message : String(error)}`);
60
+ return;
61
+ }
62
+ // Step 6: Generate Tailwind CSS
63
+ logger.log('\nšŸŽØ Generating Tailwind theme CSS...');
64
+ const css = generateTailwindThemeCss(themes, prefix, runtimePrefix, schema);
65
+ const typesOutputPath = schema.typesOutputPath ?? outputPath.replace(/\.css$/, '.d.ts');
66
+ if (typesOutputPath === outputPath) {
67
+ throw new Error('A custom outputPath must end in .css or be paired with a distinct typesOutputPath.');
68
+ }
69
+ // Step 7: Write the generated CSS file
70
+ const outputDir = outputPath.substring(0, outputPath.lastIndexOf('/'));
71
+ if (outputDir && !tree.exists(outputDir)) {
72
+ logger.log(`šŸ“ Creating directory: ${outputDir}`);
73
+ }
74
+ tree.write(outputPath, css);
75
+ logger.log(`āœ… Generated Tailwind themes at: ${outputPath}`);
76
+ // Step 8: Generate the `EthleteColorThemeNameRegistry` augmentation, so `etProvideColor`
77
+ // (and anything else that accepts a `RegisteredColorThemeName`) is checked/autocompleted
78
+ // against this app's actual theme names, instead of a plain `string`.
79
+ const typesDts = generateColorThemeNameTypes(themes, schema);
80
+ tree.write(typesOutputPath, typesDts);
81
+ logger.log(`āœ… Generated color theme name types at: ${typesOutputPath}`);
82
+ // Step 9: Try to find and update main styles file
83
+ const mainStylesFiles = findMainStylesFile(tree);
84
+ if (mainStylesFiles.length > 0) {
85
+ logger.log('\nšŸ“ Found potential main styles files:');
86
+ mainStylesFiles.forEach((file) => logger.log(` - ${file}`));
87
+ logger.log('\nāš ļø Please manually import the generated themes:');
88
+ logger.log(` @import './${outputPath.replace('src/styles/', '')}';`);
89
+ }
90
+ if (!schema.skipFormat) {
91
+ await formatFiles(tree);
92
+ }
93
+ logger.log('\nāœ… Generation completed successfully!\n');
94
+ }
95
+ //#endregion
96
+ //#region Helper Functions
97
+ function extractThemesFromContent(content, filePath) {
98
+ // Create an in-memory TypeScript project
99
+ const project = new Project({
100
+ useInMemoryFileSystem: true,
101
+ compilerOptions: {
102
+ target: 99, // ESNext
103
+ module: 99, // ESNext
104
+ },
105
+ });
106
+ // Add the source file
107
+ const sourceFile = project.createSourceFile(filePath, content);
108
+ const themes = [];
109
+ // Find all exported const declarations
110
+ const exportedDeclarations = sourceFile.getVariableDeclarations().filter((decl) => {
111
+ const statement = decl.getVariableStatement();
112
+ return statement?.isExported();
113
+ });
114
+ // Look for the THEMES or themes array
115
+ const themesArray = exportedDeclarations.find((decl) => {
116
+ const name = decl.getName();
117
+ return name === 'THEMES' || name === 'themes';
118
+ });
119
+ if (!themesArray) {
120
+ throw new Error('Could not find THEMES or themes export');
121
+ }
122
+ let initializer = themesArray.getInitializer();
123
+ if (!initializer) {
124
+ throw new Error('THEMES export has no initializer');
125
+ }
126
+ // Handle 'satisfies X'/'as const' wrappers, in either order: [array] satisfies Type[],
127
+ // [array] as const, or both combined.
128
+ while (initializer.isKind(SyntaxKind.SatisfiesExpression) || initializer.isKind(SyntaxKind.AsExpression)) {
129
+ initializer = initializer.getExpression();
130
+ }
131
+ if (!initializer.isKind(SyntaxKind.ArrayLiteralExpression)) {
132
+ throw new Error('THEMES export must be an array literal');
133
+ }
134
+ // Get the array elements (these are references to the theme const declarations)
135
+ const elements = initializer.getElements();
136
+ for (const element of elements) {
137
+ let themeObj;
138
+ let name = '<inline>';
139
+ if (element.isKind(SyntaxKind.Identifier)) {
140
+ name = element.getText();
141
+ const themeDecl = exportedDeclarations.find((decl) => decl.getName() === name);
142
+ if (!themeDecl) {
143
+ logger.warn(`āš ļø Could not find declaration for theme: ${name}`);
144
+ continue;
145
+ }
146
+ themeObj = themeDecl.getInitializer();
147
+ if (!themeObj) {
148
+ logger.warn(`āš ļø Theme ${name} has no initializer`);
149
+ continue;
150
+ }
151
+ }
152
+ else {
153
+ themeObj = element;
154
+ }
155
+ while (themeObj.isKind(SyntaxKind.AsExpression) || themeObj.isKind(SyntaxKind.SatisfiesExpression)) {
156
+ themeObj = themeObj.getExpression();
157
+ }
158
+ if (!themeObj.isKind(SyntaxKind.ObjectLiteralExpression)) {
159
+ logger.warn(`āš ļø Theme ${name} is not an object literal`);
160
+ continue;
161
+ }
162
+ try {
163
+ const theme = parseThemeObject(themeObj, sourceFile);
164
+ themes.push(theme);
165
+ }
166
+ catch (error) {
167
+ logger.warn(`āš ļø Failed to parse theme ${name}: ${error instanceof Error ? error.message : String(error)}`);
168
+ }
169
+ }
170
+ if (themes.length === 0) {
171
+ throw new Error('No valid themes found in THEMES array');
172
+ }
173
+ return themes;
174
+ }
175
+ function parseThemeObject(obj, sourceFile) {
176
+ const properties = obj.getProperties();
177
+ const theme = {};
178
+ for (const prop of properties) {
179
+ if (!prop.isKind(SyntaxKind.PropertyAssignment)) {
180
+ continue;
181
+ }
182
+ const propName = prop.getName();
183
+ const initializer = prop.getInitializer();
184
+ if (!initializer) {
185
+ continue;
186
+ }
187
+ switch (propName) {
188
+ case 'name':
189
+ if (initializer.isKind(SyntaxKind.StringLiteral)) {
190
+ theme.name = initializer.getLiteralValue();
191
+ }
192
+ break;
193
+ case 'isDefault':
194
+ if (initializer.isKind(SyntaxKind.TrueKeyword)) {
195
+ theme.isDefault = true;
196
+ }
197
+ break;
198
+ case 'type':
199
+ if (initializer.isKind(SyntaxKind.StringLiteral)) {
200
+ theme.type = initializer.getLiteralValue();
201
+ }
202
+ break;
203
+ case 'primary':
204
+ case 'secondary':
205
+ case 'tertiary':
206
+ if (initializer.isKind(SyntaxKind.ObjectLiteralExpression)) {
207
+ theme[propName] = parseThemeSwatch(initializer, sourceFile);
208
+ }
209
+ break;
210
+ }
211
+ }
212
+ if (!theme.name || !theme.primary) {
213
+ throw new Error('Theme must have name and primary properties');
214
+ }
215
+ return theme;
216
+ }
217
+ // Makes the named theme the sole default, overriding any isDefault flags in the theme
218
+ // definitions. Matches the theme's name or its CSS-safe form. Returns the resolved name.
219
+ function applyDefaultThemeOverride(themes, defaultTheme) {
220
+ const wanted = createCssThemeName(defaultTheme);
221
+ const target = themes.find((t) => t.name === defaultTheme || createCssThemeName(t.name) === wanted);
222
+ if (!target) {
223
+ throw new Error(`No theme named "${defaultTheme}" found. Available themes: ${themes.map((t) => t.name).join(', ')}`);
224
+ }
225
+ for (const theme of themes) {
226
+ theme.isDefault = theme === target;
227
+ }
228
+ return target.name;
229
+ }
230
+ function validateThemeConfiguration(themes) {
231
+ const defaultThemes = themes.filter((t) => t.isDefault);
232
+ // Error: No default theme
233
+ if (defaultThemes.length === 0) {
234
+ throw new Error('No default theme found. At least one theme must have isDefault: true');
235
+ }
236
+ // Error: Multiple default themes
237
+ if (defaultThemes.length > 1) {
238
+ throw new Error(`Multiple default themes found: ${defaultThemes.map((t) => t.name).join(', ')}. Only one theme can have isDefault: true`);
239
+ }
240
+ // Error: Duplicate theme types
241
+ const typeMap = new Map();
242
+ for (const theme of themes) {
243
+ if (!theme.type) {
244
+ continue;
245
+ }
246
+ const existing = typeMap.get(theme.type) || [];
247
+ existing.push(theme.name);
248
+ typeMap.set(theme.type, existing);
249
+ }
250
+ for (const [type, names] of typeMap) {
251
+ if (names.length > 1) {
252
+ throw new Error(`Multiple themes with type "${type}" found: ${names.join(', ')}. Only one theme can have a given type`);
253
+ }
254
+ }
255
+ }
256
+ function parseThemeSwatch(obj, sourceFile) {
257
+ const properties = obj.getProperties();
258
+ const swatch = {};
259
+ for (const prop of properties) {
260
+ if (!prop.isKind(SyntaxKind.PropertyAssignment)) {
261
+ continue;
262
+ }
263
+ const propName = prop.getName();
264
+ const initializer = prop.getInitializer();
265
+ if (!initializer) {
266
+ continue;
267
+ }
268
+ const colorMap = parseColorMap(initializer, sourceFile);
269
+ if (!colorMap) {
270
+ continue;
271
+ }
272
+ if (propName === 'color') {
273
+ swatch.color = colorMap;
274
+ }
275
+ else if (propName === 'onColor') {
276
+ swatch.onColor = colorMap;
277
+ }
278
+ else if (propName === 'inkColor') {
279
+ swatch.inkColor = colorMap;
280
+ }
281
+ }
282
+ if (!swatch.color || !swatch.onColor) {
283
+ throw new Error('ThemeSwatch must have color and onColor properties');
284
+ }
285
+ return swatch;
286
+ }
287
+ const COLOR_MAP_KEYS = ['default', 'hover', 'focus', 'active', 'disabled'];
288
+ const isColorMapKey = (name) => COLOR_MAP_KEYS.some((key) => key === name);
289
+ function parseColorMap(initializer, sourceFile) {
290
+ if (initializer.isKind(SyntaxKind.Identifier)) {
291
+ const declaration = sourceFile.getVariableDeclarations().find((decl) => decl.getName() === initializer.getText());
292
+ const referencedInitializer = declaration?.getInitializer();
293
+ return referencedInitializer ? parseColorMap(referencedInitializer, sourceFile) : null;
294
+ }
295
+ // Handle spread expressions by resolving references
296
+ if (initializer.isKind(SyntaxKind.ObjectLiteralExpression)) {
297
+ const colorMap = {};
298
+ const properties = initializer.getProperties();
299
+ for (const prop of properties) {
300
+ if (prop.isKind(SyntaxKind.PropertyAssignment)) {
301
+ const propName = prop.getName();
302
+ const propValue = prop.getInitializer();
303
+ if (propValue?.isKind(SyntaxKind.StringLiteral) && isColorMapKey(propName)) {
304
+ colorMap[propName] = propValue.getLiteralValue();
305
+ }
306
+ }
307
+ else if (prop.isKind(SyntaxKind.SpreadAssignment)) {
308
+ // Handle spread: { ...onColorDark, disabled: '...' }
309
+ const spreadExpr = prop.getExpression();
310
+ if (spreadExpr.isKind(SyntaxKind.Identifier)) {
311
+ const referencedName = spreadExpr.getText();
312
+ const referencedDecl = sourceFile.getVariableDeclarations().find((decl) => decl.getName() === referencedName);
313
+ if (referencedDecl) {
314
+ const referencedObj = referencedDecl.getInitializer();
315
+ if (referencedObj?.isKind(SyntaxKind.ObjectLiteralExpression)) {
316
+ const spreadColors = parseColorMap(referencedObj, sourceFile);
317
+ if (spreadColors) {
318
+ Object.assign(colorMap, spreadColors);
319
+ }
320
+ }
321
+ }
322
+ }
323
+ }
324
+ }
325
+ // Apply fallbacks for required fields
326
+ const defaultColor = colorMap.default;
327
+ if (defaultColor) {
328
+ // Check if this is a ThemeColorMap (has hover, active, or disabled explicitly set)
329
+ const isThemeColorMap = colorMap.hover !== undefined || colorMap.active !== undefined || colorMap.disabled !== undefined;
330
+ if (isThemeColorMap) {
331
+ // For ThemeColorMap - apply fallbacks for all required fields
332
+ const hoverColor = colorMap.hover || defaultColor;
333
+ const result = {
334
+ default: defaultColor,
335
+ hover: hoverColor,
336
+ focus: colorMap.focus,
337
+ active: colorMap.active || hoverColor,
338
+ disabled: colorMap.disabled || defaultColor,
339
+ };
340
+ return result;
341
+ }
342
+ // For OnThemeColorMap - no fallbacks needed, all fields are optional except default
343
+ return colorMap;
344
+ }
345
+ return null;
346
+ }
347
+ return null;
348
+ }
349
+ function createCssThemeName(name) {
350
+ // Convert theme name to CSS-safe format (e.g., "Primary Blue" -> "primary-blue")
351
+ return name
352
+ .toLowerCase()
353
+ .replace(/\s+/g, '-')
354
+ .replace(/[^a-z0-9-]/g, '');
355
+ }
356
+ function generateColorThemeNameTypes(themes, schema) {
357
+ const themesPath = schema.themesPath || 'src/themes.ts';
358
+ const outputPath = schema.outputPath || 'src/styles/generated-tailwind-themes.css';
359
+ const typesOutputPath = schema.typesOutputPath || outputPath.replace(/\.css$/, '.d.ts');
360
+ const names = themes.map((theme) => `'${theme.name}'`).join(' | ');
361
+ return `/*
362
+ * Auto-generated by @ethlete/core:tailwind-4-color-theme
363
+ * DO NOT EDIT THIS FILE MANUALLY
364
+ *
365
+ * Regenerate by running:
366
+ * nx g @ethlete/core:tailwind-4-color-theme --themesPath=${themesPath}${schema.outputPath ? ` --outputPath=${outputPath}` : ''}${schema.typesOutputPath ? ` --typesOutputPath=${typesOutputPath}` : ''}${schema.prefix && schema.prefix !== 'et' ? ` --prefix=${schema.prefix}` : ''}${schema.runtimePrefix && schema.runtimePrefix !== schema.prefix ? ` --runtimePrefix=${schema.runtimePrefix}` : ''}${schema.defaultTheme ? ` --defaultTheme=${schema.defaultTheme}` : ''}
367
+ */
368
+
369
+ declare module '@ethlete/core' {
370
+ interface EthleteColorThemeNameRegistry {
371
+ name: ${names};
372
+ }
373
+ }
374
+
375
+ export {};
376
+ `;
377
+ }
378
+ function generateTailwindThemeCss(themes, utilityPrefix, runtimePrefix, schema) {
379
+ const tailwindVars = [];
380
+ const themeVars = [];
381
+ const themesPath = schema.themesPath || 'src/themes.ts';
382
+ const outputPath = schema.outputPath || 'src/styles/generated-tailwind-themes.css';
383
+ const header = `/*
384
+ * Auto-generated Tailwind 4 theme colors from @ethlete/core
385
+ * DO NOT EDIT THIS FILE MANUALLY
386
+ *
387
+ * Generated from your theme definitions
388
+ * This file can be regenerated by running:
389
+ * nx g @ethlete/core:tailwind-4-color-theme --themesPath=${themesPath}${schema.outputPath ? ` --outputPath=${outputPath}` : ''}${schema.prefix && schema.prefix !== 'et' ? ` --prefix=${schema.prefix}` : ''}${schema.runtimePrefix && schema.runtimePrefix !== utilityPrefix ? ` --runtimePrefix=${schema.runtimePrefix}` : ''}${schema.defaultTheme ? ` --defaultTheme=${schema.defaultTheme}` : ''}
390
+ */
391
+
392
+ `;
393
+ // Validation is now done separately before this function is called
394
+ const defaultThemes = themes.filter((t) => t.isDefault);
395
+ const regularThemes = themes.filter((t) => !t.isDefault);
396
+ // Generate static Tailwind @theme block for each theme
397
+ for (const theme of themes) {
398
+ const name = createCssThemeName(theme.name);
399
+ // Add comment for theme section
400
+ tailwindVars.push(` /* ${theme.name} theme */`);
401
+ // Primary colors for Tailwind utilities
402
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}`, theme.primary.color);
403
+ tailwindVars.push('');
404
+ // On colors for Tailwind utilities
405
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-on-${name}`, theme.primary.onColor);
406
+ tailwindVars.push('');
407
+ // Ink colors for standalone foreground usage
408
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-ink`, theme.primary.inkColor || theme.primary.color);
409
+ tailwindVars.push('');
410
+ // Secondary colors if present
411
+ if (theme.secondary) {
412
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-secondary`, theme.secondary.color);
413
+ tailwindVars.push('');
414
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-on-${name}-secondary`, theme.secondary.onColor);
415
+ tailwindVars.push('');
416
+ }
417
+ // Tertiary colors if present
418
+ if (theme.tertiary) {
419
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-${name}-tertiary`, theme.tertiary.color);
420
+ tailwindVars.push('');
421
+ addTailwindColorVariants(tailwindVars, `${utilityPrefix}-on-${name}-tertiary`, theme.tertiary.onColor);
422
+ tailwindVars.push('');
423
+ }
424
+ }
425
+ tailwindVars.push('');
426
+ // Main theme dynamic colors
427
+ const mainThemes = [...defaultThemes, ...regularThemes];
428
+ const hasSecondary = mainThemes.some((t) => t.secondary);
429
+ const hasTertiary = mainThemes.some((t) => t.tertiary);
430
+ // Collect the dynamic theme colors once. They are emitted in `@theme` (so Tailwind generates
431
+ // the utilities) and ALSO re-declared on every color selector in the alias block below. A
432
+ // Tailwind `@theme` variable only lands on `:root`, so `rgb(var(--<runtime>-color-*))` resolves
433
+ // once against the root color scope and inherits that concrete color into descendants - which
434
+ // means `bg-<prefix>-theme-*` utilities would ignore nested `.<runtime>-color--*` scopes.
435
+ // Re-declaring them per color selector makes the utilities resolve against the nearest scope.
436
+ const dynamicColorVars = [];
437
+ addDynamicThemeColors(dynamicColorVars, utilityPrefix, runtimePrefix, 'theme', 'primary', false);
438
+ addDynamicInkColors(dynamicColorVars, utilityPrefix, runtimePrefix, 'theme-ink', 'primary-ink');
439
+ if (hasSecondary) {
440
+ addDynamicThemeColors(dynamicColorVars, utilityPrefix, runtimePrefix, 'theme-secondary', 'secondary', false);
441
+ }
442
+ if (hasTertiary) {
443
+ addDynamicThemeColors(dynamicColorVars, utilityPrefix, runtimePrefix, 'theme-tertiary', 'tertiary', false);
444
+ }
445
+ tailwindVars.push(' /* Dynamic theme colors (references runtime CSS variables) */');
446
+ tailwindVars.push(...dynamicColorVars);
447
+ // Generate runtime CSS for all themes
448
+ themes.forEach((theme) => {
449
+ const name = createCssThemeName(theme.name);
450
+ // Determine if this is the default theme
451
+ const isDefault = theme.isDefault;
452
+ // Generate color variant (.<runtimePrefix>-color--{name})
453
+ if (isDefault) {
454
+ // Default color gets :root and .<runtimePrefix>-color--default selectors
455
+ const selectors = [':root', `.${runtimePrefix}-color--default`, `.${runtimePrefix}-color--${name}`];
456
+ themeVars.push(`${selectors.join(', ')} {`);
457
+ }
458
+ else {
459
+ // Regular colors just get their own class
460
+ themeVars.push(`.${runtimePrefix}-color--${name} {`);
461
+ }
462
+ addThemeColorVariants(themeVars, runtimePrefix, '', theme);
463
+ themeVars.push('}\n');
464
+ });
465
+ // Re-indent the dynamic theme colors (from 2-space `@theme` indent to the 4-space alias-block
466
+ // indent) and drop trailing blank lines so they slot cleanly into the alias selector below.
467
+ const dynamicColorAliasLines = dynamicColorVars.map((line) => (line === '' ? '' : ` ${line}`));
468
+ while (dynamicColorAliasLines.length && dynamicColorAliasLines[dynamicColorAliasLines.length - 1] === '') {
469
+ dynamicColorAliasLines.pop();
470
+ }
471
+ // Convenience var aliases - available on any element with a color class (or root for default)
472
+ const aliasBlock = `/* Convenience aliases (rgb + solid + opacity variants) */
473
+ @layer base {
474
+ :root, :where([class*="${runtimePrefix}-color--"]) {
475
+ --${runtimePrefix}-theme-color-primary-rgb: var(--${runtimePrefix}-color-primary);
476
+ --${runtimePrefix}-theme-color-primary-opacity: 1;
477
+ --${runtimePrefix}-theme-color-primary-solid: rgb(var(--${runtimePrefix}-theme-color-primary-rgb));
478
+ --${runtimePrefix}-theme-color-primary: rgb(var(--${runtimePrefix}-theme-color-primary-rgb) / var(--${runtimePrefix}-theme-color-primary-opacity));
479
+
480
+ --${runtimePrefix}-theme-color-on-primary-rgb: var(--${runtimePrefix}-color-on-primary);
481
+ --${runtimePrefix}-theme-color-on-primary-opacity: 1;
482
+ --${runtimePrefix}-theme-color-on-primary-solid: rgb(var(--${runtimePrefix}-theme-color-on-primary-rgb) / var(--${runtimePrefix}-theme-color-on-primary-opacity));
483
+ --${runtimePrefix}-theme-color-on-primary: rgb(var(--${runtimePrefix}-theme-color-on-primary-rgb) / var(--${runtimePrefix}-theme-color-on-primary-opacity));
484
+
485
+ --${runtimePrefix}-theme-color-ink-rgb: var(--${runtimePrefix}-color-primary-ink, var(--${runtimePrefix}-color-primary));
486
+ --${runtimePrefix}-theme-color-ink-opacity: 1;
487
+ --${runtimePrefix}-theme-color-ink-solid: rgb(var(--${runtimePrefix}-theme-color-ink-rgb) / var(--${runtimePrefix}-theme-color-ink-opacity));
488
+ --${runtimePrefix}-theme-color-ink: rgb(var(--${runtimePrefix}-theme-color-ink-rgb) / var(--${runtimePrefix}-theme-color-ink-opacity));
489
+
490
+ /* Dynamic Tailwind theme colors - re-declared per color scope so that
491
+ bg-${utilityPrefix}-theme-* utilities resolve against the nearest .${runtimePrefix}-color--*
492
+ scope instead of the value computed once at :root. */
493
+ ${dynamicColorAliasLines.join('\n')}
494
+ }
495
+ }
496
+ `;
497
+ return `${header}@theme {
498
+ ${tailwindVars.join('\n')}
499
+ }
500
+
501
+ @layer base {
502
+ ${themeVars.join('\n')}
503
+ }
504
+
505
+ ${aliasBlock}`;
506
+ }
507
+ function addDynamicThemeColors(vars, utilityPrefix, runtimePrefix, tailwindName, cssVarName, addSpacingBefore) {
508
+ if (addSpacingBefore && vars.length > 0 && vars[vars.length - 1] !== '') {
509
+ vars.push('');
510
+ }
511
+ // Color variants
512
+ vars.push(` --color-${utilityPrefix}-${tailwindName}: rgb(var(--${runtimePrefix}-color-${cssVarName}));`);
513
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-hover: rgb(var(--${runtimePrefix}-color-${cssVarName}-hover));`);
514
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-focus: rgb(var(--${runtimePrefix}-color-${cssVarName}-focus));`);
515
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-active: rgb(var(--${runtimePrefix}-color-${cssVarName}-active));`);
516
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-disabled: rgb(var(--${runtimePrefix}-color-${cssVarName}-disabled));`);
517
+ vars.push('');
518
+ // On-color variants
519
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}: rgb(var(--${runtimePrefix}-color-on-${cssVarName}));`);
520
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-hover: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-hover));`);
521
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-focus: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-focus));`);
522
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-active: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-active));`);
523
+ vars.push(` --color-${utilityPrefix}-on-${tailwindName}-disabled: rgb(var(--${runtimePrefix}-color-on-${cssVarName}-disabled));`);
524
+ vars.push('');
525
+ }
526
+ function addDynamicInkColors(vars, utilityPrefix, runtimePrefix, tailwindName, cssVarName) {
527
+ vars.push(` --color-${utilityPrefix}-${tailwindName}: rgb(var(--${runtimePrefix}-color-${cssVarName}));`);
528
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-hover: rgb(var(--${runtimePrefix}-color-${cssVarName}-hover));`);
529
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-focus: rgb(var(--${runtimePrefix}-color-${cssVarName}-focus));`);
530
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-active: rgb(var(--${runtimePrefix}-color-${cssVarName}-active));`);
531
+ vars.push(` --color-${utilityPrefix}-${tailwindName}-disabled: rgb(var(--${runtimePrefix}-color-${cssVarName}-disabled));`);
532
+ vars.push('');
533
+ }
534
+ function addTailwindColorVariants(vars, colorName, colorSet) {
535
+ // Tailwind 4 requires --color-* prefix and rgb() wrapper
536
+ // Always generate all variants with fallbacks
537
+ vars.push(` --color-${colorName}: rgb(${colorSet.default});`);
538
+ // For hover: use hover if exists, otherwise default
539
+ const hoverValue = 'hover' in colorSet && colorSet.hover ? colorSet.hover : colorSet.default;
540
+ vars.push(` --color-${colorName}-hover: rgb(${hoverValue});`);
541
+ // For focus: use focus if exists, otherwise hover, otherwise default
542
+ const focusValue = 'focus' in colorSet && colorSet.focus ? colorSet.focus : hoverValue;
543
+ vars.push(` --color-${colorName}-focus: rgb(${focusValue});`);
544
+ // For active: use active if exists, otherwise hover, otherwise default
545
+ const activeValue = 'active' in colorSet && colorSet.active ? colorSet.active : hoverValue;
546
+ vars.push(` --color-${colorName}-active: rgb(${activeValue});`);
547
+ // For disabled: use disabled if exists, otherwise default
548
+ const disabledValue = 'disabled' in colorSet && colorSet.disabled ? colorSet.disabled : colorSet.default;
549
+ vars.push(` --color-${colorName}-disabled: rgb(${disabledValue});`);
550
+ }
551
+ function addThemeColorVariants(vars, prefix, altPrefix, theme) {
552
+ const addSwatch = (level, swatch) => {
553
+ // Color variants with fallbacks
554
+ const defaultColor = swatch.color.default;
555
+ const hoverColor = swatch.color.hover || defaultColor;
556
+ const focusColor = swatch.color.focus || hoverColor;
557
+ const activeColor = swatch.color.active || hoverColor;
558
+ const disabledColor = swatch.color.disabled || defaultColor;
559
+ vars.push(` --${prefix}-color-${altPrefix}${level}: ${defaultColor};`);
560
+ vars.push(` --${prefix}-color-${altPrefix}${level}-hover: ${hoverColor};`);
561
+ vars.push(` --${prefix}-color-${altPrefix}${level}-focus: ${focusColor};`);
562
+ vars.push(` --${prefix}-color-${altPrefix}${level}-active: ${activeColor};`);
563
+ vars.push(` --${prefix}-color-${altPrefix}${level}-disabled: ${disabledColor};`);
564
+ vars.push('');
565
+ // On color variants with fallbacks
566
+ const onDefaultColor = swatch.onColor.default;
567
+ const onHoverColor = swatch.onColor.hover || onDefaultColor;
568
+ const onFocusColor = swatch.onColor.focus || onHoverColor;
569
+ const onActiveColor = swatch.onColor.active || onDefaultColor;
570
+ const onDisabledColor = swatch.onColor.disabled || onDefaultColor;
571
+ vars.push(` --${prefix}-color-${altPrefix}on-${level}: ${onDefaultColor};`);
572
+ vars.push(` --${prefix}-color-${altPrefix}on-${level}-hover: ${onHoverColor};`);
573
+ vars.push(` --${prefix}-color-${altPrefix}on-${level}-focus: ${onFocusColor};`);
574
+ vars.push(` --${prefix}-color-${altPrefix}on-${level}-active: ${onActiveColor};`);
575
+ vars.push(` --${prefix}-color-${altPrefix}on-${level}-disabled: ${onDisabledColor};`);
576
+ const inkDefaultColor = swatch.inkColor?.default || defaultColor;
577
+ const inkHoverColor = swatch.inkColor?.hover || inkDefaultColor;
578
+ const inkFocusColor = swatch.inkColor?.focus || inkHoverColor;
579
+ const inkActiveColor = swatch.inkColor?.active || inkDefaultColor;
580
+ const inkDisabledColor = swatch.inkColor?.disabled || inkDefaultColor;
581
+ vars.push('');
582
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink: ${inkDefaultColor};`);
583
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-hover: ${inkHoverColor};`);
584
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-focus: ${inkFocusColor};`);
585
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-active: ${inkActiveColor};`);
586
+ vars.push(` --${prefix}-color-${altPrefix}${level}-ink-disabled: ${inkDisabledColor};`);
587
+ if (theme.secondary || theme.tertiary) {
588
+ vars.push('');
589
+ }
590
+ };
591
+ addSwatch('primary', theme.primary);
592
+ if (theme.secondary) {
593
+ addSwatch('secondary', theme.secondary);
594
+ }
595
+ if (theme.tertiary) {
596
+ addSwatch('tertiary', theme.tertiary);
597
+ }
598
+ }
599
+ function findMainStylesFile(tree) {
600
+ const potentialFiles = [];
601
+ visitNotIgnoredFiles(tree, '', (path) => {
602
+ if (path.match(/styles\.(css|scss)$/) && !path.includes('node_modules') && !path.includes('dist')) {
603
+ potentialFiles.push(path);
604
+ }
605
+ });
606
+ return potentialFiles;
607
+ }
608
+ //#endregion
@@ -0,0 +1,44 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "tailwind-4-color-theme",
4
+ "title": "Tailwind 4 Theming Generator",
5
+ "description": "Generates Tailwind 4 compatible CSS from your Ethlete theme definitions",
6
+ "type": "object",
7
+ "properties": {
8
+ "themesPath": {
9
+ "type": "string",
10
+ "description": "Path to your themes file (relative to workspace root)",
11
+ "default": "src/themes.ts",
12
+ "x-prompt": "Where is your themes file located?"
13
+ },
14
+ "outputPath": {
15
+ "type": "string",
16
+ "description": "Where to output the generated Tailwind themes CSS",
17
+ "default": "src/styles/generated-tailwind-themes.css",
18
+ "x-prompt": "Where should the generated CSS be saved?"
19
+ },
20
+ "typesOutputPath": {
21
+ "type": "string",
22
+ "description": "Where to output the generated `EthleteColorThemeNameRegistry` augmentation (a .d.ts that narrows `etProvideColor` and other theme-name APIs to your actual theme names). Defaults to `outputPath` with its extension swapped to `.d.ts`."
23
+ },
24
+ "prefix": {
25
+ "type": "string",
26
+ "description": "Prefix for Tailwind utility color variable names (e.g., 'et' creates --color-et-primary, usable as bg-et-primary)",
27
+ "default": "et"
28
+ },
29
+ "runtimePrefix": {
30
+ "type": "string",
31
+ "description": "Prefix for the runtime theme-swap mechanism: the CSS custom properties (--<runtimePrefix>-color-primary) and selectors (.<runtimePrefix>-color--<name>) that ProvideColorDirective (etProvideColor) toggles between. Defaults to the same value as 'prefix'. Set this separately when your Tailwind utility prefix needs to differ from your runtime theme-switching prefix (e.g. because '--color-primary' is already a documented public API elsewhere in your app)."
32
+ },
33
+ "defaultTheme": {
34
+ "type": "string",
35
+ "description": "Name of the theme to use as the default, overriding any isDefault flags in the theme definitions. Use this when multiple apps share one theme definition set (e.g. in a monorepo) but each app needs a different default."
36
+ },
37
+ "skipFormat": {
38
+ "type": "boolean",
39
+ "description": "Skip formatting files after generation",
40
+ "default": false
41
+ }
42
+ },
43
+ "required": ["themesPath"]
44
+ }