@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.
- package/CHANGELOG.md +686 -0
- package/README.md +28 -4
- package/fesm2022/ethlete-core.mjs +8020 -3527
- package/fesm2022/ethlete-core.mjs.map +1 -1
- package/generators/devtools-about/generator.js +122 -0
- package/generators/devtools-about/schema.json +25 -0
- package/generators/generators.json +26 -5
- package/generators/migrate-provider-shape/migration-scope.js +35 -0
- package/generators/migrate-provider-shape/migration.js +50 -0
- package/generators/migrate-provider-shape/provider-shape.js +284 -0
- package/generators/migrate-provider-shape/schema.json +26 -0
- package/generators/migrate-surface-interaction-swatch/interaction-swatch.js +63 -0
- package/generators/migrate-surface-interaction-swatch/migration.js +36 -0
- package/generators/migrate-surface-interaction-swatch/schema.json +26 -0
- package/generators/migrate-to-v5/color-naming.js +170 -0
- package/generators/migrate-to-v5/create-provider.js +14 -14
- package/generators/migrate-to-v5/migration.js +11 -1
- package/generators/migrate-to-v5/removed-exports.js +93 -0
- package/generators/migrate-to-v5/router-state-service.js +33 -8
- package/generators/migrate-to-v5/schema.json +10 -0
- package/generators/migrate-to-v5/viewport-service.js +41 -60
- package/generators/tailwind-4-color-theme/generator.js +608 -0
- package/generators/tailwind-4-color-theme/schema.json +44 -0
- package/generators/tailwind-4-surface-theme/generator.js +599 -0
- package/generators/tailwind-4-surface-theme/schema.json +48 -0
- package/migrations/seo-directive-removed.md +80 -0
- package/migrations/surface-theme-regenerate.md +20 -0
- package/migrations.json +43 -0
- package/package.json +25 -14
- package/types/ethlete-core.d.ts +3329 -1774
- package/generators/tailwind-4-theme/generator.js +0 -490
- 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
|
+
}
|