@ethlete/core 5.0.0-next.61 → 5.0.0-next.63
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 +56 -0
- package/fesm2022/ethlete-core.mjs +774 -405
- package/fesm2022/ethlete-core.mjs.map +1 -1
- package/generators/generators.json +10 -0
- package/generators/migrate-create-destroy/create-destroy.js +317 -0
- package/generators/migrate-create-destroy/migration.js +46 -0
- package/generators/migrate-create-destroy/schema.json +26 -0
- package/generators/migrate-legacy-color-themes/legacy-color-themes.js +123 -0
- package/generators/migrate-legacy-color-themes/migration.js +93 -0
- package/generators/migrate-legacy-color-themes/schema.json +26 -0
- package/generators/migrate-to-v5/removed-exports.js +94 -6
- package/migrations/legacy-color-themes.md +38 -0
- package/migrations/seo-directive-removed.md +2 -1
- package/migrations.json +15 -0
- package/package.json +11 -11
- package/types/ethlete-core.d.ts +269 -174
|
@@ -11,11 +11,21 @@
|
|
|
11
11
|
"schema": "./migrate-provider-shape/schema.json",
|
|
12
12
|
"description": "Rewrite destructured provider tuples into the tree-shakeable definition shape"
|
|
13
13
|
},
|
|
14
|
+
"migrate-create-destroy": {
|
|
15
|
+
"implementation": "./migrate-create-destroy/migration",
|
|
16
|
+
"schema": "./migrate-create-destroy/schema.json",
|
|
17
|
+
"description": "Rewrite takeUntil(this.destroy$) on a createDestroy() field into takeUntilDestroyed()"
|
|
18
|
+
},
|
|
14
19
|
"migrate-surface-interaction-swatch": {
|
|
15
20
|
"implementation": "./migrate-surface-interaction-swatch/migration",
|
|
16
21
|
"schema": "./migrate-surface-interaction-swatch/schema.json",
|
|
17
22
|
"description": "Rewrite surface theme interactionColor maps into the { color, onColor, inkColor } swatch shape"
|
|
18
23
|
},
|
|
24
|
+
"migrate-legacy-color-themes": {
|
|
25
|
+
"implementation": "./migrate-legacy-color-themes/migration",
|
|
26
|
+
"schema": "./migrate-legacy-color-themes/schema.json",
|
|
27
|
+
"description": "Rewrite provideColorThemes into provideColorThemesWithTailwind4 and list the Tailwind 4 setup that is left"
|
|
28
|
+
},
|
|
19
29
|
"tailwind-4-color-theme": {
|
|
20
30
|
"implementation": "./tailwind-4-color-theme/generator",
|
|
21
31
|
"schema": "./tailwind-4-color-theme/schema.json",
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
const isEthleteModule = (specifier) => specifier.startsWith('@ethlete/');
|
|
3
|
+
const isRxjsModule = (specifier) => specifier === 'rxjs' || specifier === 'rxjs/operators';
|
|
4
|
+
const importedLocalNames = (sourceFile, accepts, importedName) => {
|
|
5
|
+
const names = new Set();
|
|
6
|
+
for (const statement of sourceFile.statements) {
|
|
7
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
8
|
+
continue;
|
|
9
|
+
if (!accepts(statement.moduleSpecifier.text))
|
|
10
|
+
continue;
|
|
11
|
+
const bindings = statement.importClause?.namedBindings;
|
|
12
|
+
if (!bindings || !ts.isNamedImports(bindings))
|
|
13
|
+
continue;
|
|
14
|
+
for (const element of bindings.elements) {
|
|
15
|
+
if ((element.propertyName ?? element.name).text === importedName)
|
|
16
|
+
names.add(element.name.text);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return names;
|
|
20
|
+
};
|
|
21
|
+
const lineOf = (sourceFile, node) => sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
22
|
+
const isCallTo = (node, names) => !!node && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && names.has(node.expression.text);
|
|
23
|
+
const hasModifier = (node, kind) => !!node.modifiers?.some((m) => m.kind === kind);
|
|
24
|
+
const rebindsThis = (node) => ts.isClassLike(node) ||
|
|
25
|
+
ts.isFunctionExpression(node) ||
|
|
26
|
+
ts.isFunctionDeclaration(node) ||
|
|
27
|
+
((ts.isMethodDeclaration(node) || ts.isGetAccessor(node) || ts.isSetAccessor(node)) &&
|
|
28
|
+
ts.isObjectLiteralExpression(node.parent));
|
|
29
|
+
const collectThisAccesses = (cls, names) => {
|
|
30
|
+
const uses = new Map();
|
|
31
|
+
const visit = (node, injection) => {
|
|
32
|
+
if (rebindsThis(node))
|
|
33
|
+
return;
|
|
34
|
+
if (ts.isArrowFunction(node))
|
|
35
|
+
injection = false;
|
|
36
|
+
if (ts.isPropertyAccessExpression(node) &&
|
|
37
|
+
node.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
38
|
+
names.has(node.name.text)) {
|
|
39
|
+
const list = uses.get(node.name.text) ?? [];
|
|
40
|
+
list.push({ node, injection });
|
|
41
|
+
uses.set(node.name.text, list);
|
|
42
|
+
}
|
|
43
|
+
ts.forEachChild(node, (child) => visit(child, injection));
|
|
44
|
+
};
|
|
45
|
+
for (const member of cls.members) {
|
|
46
|
+
if (hasModifier(member, ts.SyntaxKind.StaticKeyword))
|
|
47
|
+
continue;
|
|
48
|
+
if (ts.isPropertyDeclaration(member)) {
|
|
49
|
+
if (member.initializer)
|
|
50
|
+
visit(member.initializer, true);
|
|
51
|
+
}
|
|
52
|
+
else if (ts.isConstructorDeclaration(member)) {
|
|
53
|
+
if (member.body)
|
|
54
|
+
visit(member.body, true);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
ts.forEachChild(member, (child) => visit(child, false));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return uses;
|
|
61
|
+
};
|
|
62
|
+
const memberName = (member) => member.name && (ts.isIdentifier(member.name) || ts.isPrivateIdentifier(member.name)) ? member.name.text : null;
|
|
63
|
+
const lineRange = (content, node, sourceFile) => {
|
|
64
|
+
let start = node.getStart(sourceFile, true);
|
|
65
|
+
let end = node.getEnd();
|
|
66
|
+
while (start > 0 && content[start - 1] !== '\n')
|
|
67
|
+
start--;
|
|
68
|
+
while (end < content.length && content[end] !== '\n')
|
|
69
|
+
end++;
|
|
70
|
+
if (content[end] === '\n')
|
|
71
|
+
end++;
|
|
72
|
+
return { start, end };
|
|
73
|
+
};
|
|
74
|
+
const migrateClass = (cls, ctx) => {
|
|
75
|
+
const destroyFields = [];
|
|
76
|
+
const takenNames = new Set();
|
|
77
|
+
let existingRefName = null;
|
|
78
|
+
for (const member of cls.members) {
|
|
79
|
+
const name = memberName(member);
|
|
80
|
+
if (name)
|
|
81
|
+
takenNames.add(name);
|
|
82
|
+
if (!ts.isPropertyDeclaration(member) || !name || hasModifier(member, ts.SyntaxKind.StaticKeyword))
|
|
83
|
+
continue;
|
|
84
|
+
const initializer = member.initializer;
|
|
85
|
+
if (isCallTo(initializer, ctx.destroyFns) && initializer.arguments.length === 0) {
|
|
86
|
+
destroyFields.push(member);
|
|
87
|
+
ctx.handledCalls.add(initializer);
|
|
88
|
+
}
|
|
89
|
+
else if (initializer &&
|
|
90
|
+
ts.isCallExpression(initializer) &&
|
|
91
|
+
ts.isIdentifier(initializer.expression) &&
|
|
92
|
+
initializer.expression.text === 'inject' &&
|
|
93
|
+
initializer.arguments.length === 1 &&
|
|
94
|
+
ts.isIdentifier(initializer.arguments[0]) &&
|
|
95
|
+
initializer.arguments[0].text === 'DestroyRef' &&
|
|
96
|
+
existingRefName === null &&
|
|
97
|
+
!ts.isPrivateIdentifier(member.name)) {
|
|
98
|
+
existingRefName = name;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (destroyFields.length === 0)
|
|
102
|
+
return;
|
|
103
|
+
const uses = collectThisAccesses(cls, new Set(destroyFields.map((field) => memberName(field))));
|
|
104
|
+
const isTakeUntilArg = (use) => isCallTo(use.node.parent, ctx.takeUntilFns) &&
|
|
105
|
+
use.node.parent.arguments.length === 1 &&
|
|
106
|
+
use.node.parent.arguments[0] === use.node;
|
|
107
|
+
const needsRef = [...uses.values()].some((list) => list.some((use) => isTakeUntilArg(use) && !use.injection));
|
|
108
|
+
let refName = existingRefName;
|
|
109
|
+
if (needsRef && !refName) {
|
|
110
|
+
const first = memberName(destroyFields[0]);
|
|
111
|
+
const base = first.startsWith('_') || first.startsWith('#') ? '_destroyRef' : 'destroyRef';
|
|
112
|
+
let candidate = base;
|
|
113
|
+
let suffix = 2;
|
|
114
|
+
while (takenNames.has(candidate))
|
|
115
|
+
candidate = `${base}${suffix++}`;
|
|
116
|
+
refName = candidate;
|
|
117
|
+
}
|
|
118
|
+
let refDeclared = existingRefName !== null || !needsRef;
|
|
119
|
+
for (const field of destroyFields) {
|
|
120
|
+
const name = memberName(field);
|
|
121
|
+
const fieldUses = uses.get(name) ?? [];
|
|
122
|
+
const takeUntilUses = fieldUses.filter(isTakeUntilArg);
|
|
123
|
+
const otherUses = fieldUses.filter((use) => !isTakeUntilArg(use));
|
|
124
|
+
const isPrivate = hasModifier(field, ts.SyntaxKind.PrivateKeyword) || ts.isPrivateIdentifier(field.name);
|
|
125
|
+
const removable = isPrivate && otherUses.length === 0;
|
|
126
|
+
for (const use of takeUntilUses) {
|
|
127
|
+
const call = use.node.parent;
|
|
128
|
+
ctx.edits.push({
|
|
129
|
+
start: call.getStart(ctx.sourceFile),
|
|
130
|
+
end: call.getEnd(),
|
|
131
|
+
text: `takeUntilDestroyed(${use.injection ? '' : `this.${refName}`})`,
|
|
132
|
+
});
|
|
133
|
+
ctx.needs.takeUntilDestroyed = true;
|
|
134
|
+
}
|
|
135
|
+
for (const use of otherUses) {
|
|
136
|
+
ctx.tasks.push({
|
|
137
|
+
id: `create-destroy:${ctx.filePath}:${lineOf(ctx.sourceFile, use.node)}`,
|
|
138
|
+
file: ctx.filePath,
|
|
139
|
+
line: lineOf(ctx.sourceFile, use.node),
|
|
140
|
+
message: `\`this.${name}\` is used other than as \`takeUntil(this.${name})\`, so the field stays. Replace the use by hand.`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (!isPrivate) {
|
|
144
|
+
ctx.tasks.push({
|
|
145
|
+
id: `create-destroy:${ctx.filePath}:${lineOf(ctx.sourceFile, field)}`,
|
|
146
|
+
file: ctx.filePath,
|
|
147
|
+
line: lineOf(ctx.sourceFile, field),
|
|
148
|
+
message: `\`${name}\` is not private, so it may be used outside this class and the field stays.`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const readonly = hasModifier(field, ts.SyntaxKind.ReadonlyKeyword) ? 'readonly ' : '';
|
|
152
|
+
const refDeclaration = `private ${readonly}${refName} = inject(DestroyRef);`;
|
|
153
|
+
const placesRef = !refDeclared;
|
|
154
|
+
if (placesRef) {
|
|
155
|
+
refDeclared = true;
|
|
156
|
+
ctx.needs.destroyRef = true;
|
|
157
|
+
}
|
|
158
|
+
if (removable && placesRef) {
|
|
159
|
+
ctx.edits.push({
|
|
160
|
+
start: field.getStart(ctx.sourceFile),
|
|
161
|
+
end: field.getEnd(),
|
|
162
|
+
text: refDeclaration,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
else if (removable) {
|
|
166
|
+
const { start, end } = lineRange(ctx.content, field, ctx.sourceFile);
|
|
167
|
+
ctx.edits.push({ start, end, text: '' });
|
|
168
|
+
}
|
|
169
|
+
else if (placesRef) {
|
|
170
|
+
ctx.edits.push({ start: field.getEnd(), end: field.getEnd(), text: `\n${refDeclaration}` });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
const countIdentifier = (sourceFile, name) => {
|
|
175
|
+
let count = 0;
|
|
176
|
+
const visit = (node) => {
|
|
177
|
+
if (ts.isImportDeclaration(node))
|
|
178
|
+
return;
|
|
179
|
+
if (ts.isIdentifier(node) && node.text === name)
|
|
180
|
+
count++;
|
|
181
|
+
ts.forEachChild(node, visit);
|
|
182
|
+
};
|
|
183
|
+
visit(sourceFile);
|
|
184
|
+
return count;
|
|
185
|
+
};
|
|
186
|
+
const applyEdits = (content, edits) => [...edits]
|
|
187
|
+
.sort((a, b) => b.start - a.start)
|
|
188
|
+
.reduce((text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), content);
|
|
189
|
+
const removeUnusedImports = (filePath, content) => {
|
|
190
|
+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
|
191
|
+
const edits = [];
|
|
192
|
+
for (const statement of sourceFile.statements) {
|
|
193
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
194
|
+
continue;
|
|
195
|
+
const module = statement.moduleSpecifier.text;
|
|
196
|
+
const bindings = statement.importClause?.namedBindings;
|
|
197
|
+
if (!bindings || !ts.isNamedImports(bindings))
|
|
198
|
+
continue;
|
|
199
|
+
const removeName = isEthleteModule(module) ? 'createDestroy' : isRxjsModule(module) ? 'takeUntil' : null;
|
|
200
|
+
if (!removeName)
|
|
201
|
+
continue;
|
|
202
|
+
const unused = bindings.elements.filter((element) => (element.propertyName ?? element.name).text === removeName &&
|
|
203
|
+
countIdentifier(sourceFile, element.name.text) === 0);
|
|
204
|
+
if (unused.length === 0)
|
|
205
|
+
continue;
|
|
206
|
+
const kept = bindings.elements.filter((element) => !unused.includes(element));
|
|
207
|
+
if (kept.length === 0 && !statement.importClause?.name) {
|
|
208
|
+
const { start, end } = lineRange(content, statement, sourceFile);
|
|
209
|
+
edits.push({ start, end, text: '' });
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
edits.push({
|
|
213
|
+
start: bindings.getStart(sourceFile),
|
|
214
|
+
end: bindings.getEnd(),
|
|
215
|
+
text: `{ ${kept.map((element) => element.getText(sourceFile)).join(', ')} }`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return applyEdits(content, edits);
|
|
220
|
+
};
|
|
221
|
+
const addImports = (filePath, content, wanted) => {
|
|
222
|
+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
|
223
|
+
const edits = [];
|
|
224
|
+
const fresh = new Map();
|
|
225
|
+
const joined = new Map();
|
|
226
|
+
for (const { module, name } of wanted) {
|
|
227
|
+
const declaration = sourceFile.statements.find((statement) => ts.isImportDeclaration(statement) &&
|
|
228
|
+
ts.isStringLiteral(statement.moduleSpecifier) &&
|
|
229
|
+
statement.moduleSpecifier.text === module &&
|
|
230
|
+
!statement.importClause?.isTypeOnly &&
|
|
231
|
+
!!statement.importClause?.namedBindings &&
|
|
232
|
+
ts.isNamedImports(statement.importClause.namedBindings));
|
|
233
|
+
const bindings = declaration?.importClause?.namedBindings;
|
|
234
|
+
if (!declaration || !bindings) {
|
|
235
|
+
fresh.set(module, [...(fresh.get(module) ?? []), name]);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (bindings.elements.some((element) => element.name.text === name && !element.propertyName))
|
|
239
|
+
continue;
|
|
240
|
+
joined.set(declaration, [...(joined.get(declaration) ?? []), name]);
|
|
241
|
+
}
|
|
242
|
+
for (const [declaration, names] of joined) {
|
|
243
|
+
const bindings = declaration.importClause.namedBindings;
|
|
244
|
+
const texts = bindings.elements.map((element) => element.getText(sourceFile));
|
|
245
|
+
const sorted = [...texts].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
246
|
+
const wasSorted = texts.every((text, index) => text === sorted[index]);
|
|
247
|
+
const merged = wasSorted
|
|
248
|
+
? [...texts, ...names].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
|
|
249
|
+
: [...texts, ...names];
|
|
250
|
+
edits.push({ start: bindings.getStart(sourceFile), end: bindings.getEnd(), text: `{ ${merged.join(', ')} }` });
|
|
251
|
+
}
|
|
252
|
+
if (fresh.size > 0) {
|
|
253
|
+
const anchor = sourceFile.statements.filter(ts.isImportDeclaration).at(-1);
|
|
254
|
+
const position = anchor ? anchor.getEnd() : 0;
|
|
255
|
+
const lines = [...fresh].map(([module, names]) => `import { ${[...new Set(names)].join(', ')} } from '${module}';`);
|
|
256
|
+
edits.push({
|
|
257
|
+
start: position,
|
|
258
|
+
end: position,
|
|
259
|
+
text: anchor ? `\n${lines.join('\n')}` : `${lines.join('\n')}\n`,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return applyEdits(content, edits);
|
|
263
|
+
};
|
|
264
|
+
/**
|
|
265
|
+
* Rewrites `takeUntil(this.<createDestroy field>)` into `takeUntilDestroyed(…)` in one file and drops
|
|
266
|
+
* the field once nothing else uses it. Every other use of the field is reported, never rewritten.
|
|
267
|
+
*/
|
|
268
|
+
export const migrateCreateDestroyInFile = (filePath, content) => {
|
|
269
|
+
if (!content.includes('createDestroy'))
|
|
270
|
+
return { content, changed: false, tasks: [] };
|
|
271
|
+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
|
272
|
+
const destroyFns = importedLocalNames(sourceFile, isEthleteModule, 'createDestroy');
|
|
273
|
+
if (destroyFns.size === 0)
|
|
274
|
+
return { content, changed: false, tasks: [] };
|
|
275
|
+
const ctx = {
|
|
276
|
+
sourceFile,
|
|
277
|
+
content,
|
|
278
|
+
filePath,
|
|
279
|
+
destroyFns,
|
|
280
|
+
takeUntilFns: importedLocalNames(sourceFile, isRxjsModule, 'takeUntil'),
|
|
281
|
+
edits: [],
|
|
282
|
+
tasks: [],
|
|
283
|
+
handledCalls: new Set(),
|
|
284
|
+
needs: { takeUntilDestroyed: false, destroyRef: false },
|
|
285
|
+
};
|
|
286
|
+
const visit = (node) => {
|
|
287
|
+
if (ts.isClassLike(node))
|
|
288
|
+
migrateClass(node, ctx);
|
|
289
|
+
ts.forEachChild(node, visit);
|
|
290
|
+
};
|
|
291
|
+
visit(sourceFile);
|
|
292
|
+
const visitCalls = (node) => {
|
|
293
|
+
if (isCallTo(node, destroyFns) && !ctx.handledCalls.has(node)) {
|
|
294
|
+
ctx.tasks.push({
|
|
295
|
+
id: `create-destroy:${filePath}:${lineOf(sourceFile, node)}`,
|
|
296
|
+
file: filePath,
|
|
297
|
+
line: lineOf(sourceFile, node),
|
|
298
|
+
message: 'This `createDestroy()` call is not a plain class field, so it was left alone.',
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
ts.forEachChild(node, visitCalls);
|
|
302
|
+
};
|
|
303
|
+
visitCalls(sourceFile);
|
|
304
|
+
if (ctx.edits.length === 0)
|
|
305
|
+
return { content, changed: false, tasks: ctx.tasks };
|
|
306
|
+
const wanted = [
|
|
307
|
+
...(ctx.needs.takeUntilDestroyed ? [{ module: '@angular/core/rxjs-interop', name: 'takeUntilDestroyed' }] : []),
|
|
308
|
+
...(ctx.needs.destroyRef
|
|
309
|
+
? [
|
|
310
|
+
{ module: '@angular/core', name: 'DestroyRef' },
|
|
311
|
+
{ module: '@angular/core', name: 'inject' },
|
|
312
|
+
]
|
|
313
|
+
: []),
|
|
314
|
+
];
|
|
315
|
+
const updated = addImports(filePath, removeUnusedImports(filePath, applyEdits(content, ctx.edits)), wanted);
|
|
316
|
+
return { content: updated, changed: updated !== content, tasks: ctx.tasks };
|
|
317
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { formatFiles } from '@nx/devkit';
|
|
2
|
+
import { createMigrationScope } from '../migrate-provider-shape/migration-scope.js';
|
|
3
|
+
import { migrateCreateDestroyInFile } from './create-destroy.js';
|
|
4
|
+
export const CREATE_DESTROY_REPORT_PATH = 'create-destroy-migration-tasks.md';
|
|
5
|
+
const renderReport = (tasks) => [
|
|
6
|
+
'# createDestroy migration tasks',
|
|
7
|
+
'',
|
|
8
|
+
'The codemod rewrote every `takeUntil(this.<destroy field>)` into `takeUntilDestroyed(…)`. The sites below',
|
|
9
|
+
'use the field in another way (`.next()`, passed on, piped), so they were left alone and the field stays.',
|
|
10
|
+
'',
|
|
11
|
+
...tasks.map((task) => [`## \`${task.id}\``, '', `- ${task.file}:${task.line}`, `- ${task.message}`, ''].join('\n')),
|
|
12
|
+
].join('\n');
|
|
13
|
+
export default async function migrateCreateDestroy(tree, schema) {
|
|
14
|
+
console.log('\n🔄 Migrating createDestroy to takeUntilDestroyed...');
|
|
15
|
+
const scope = createMigrationScope(tree, schema);
|
|
16
|
+
console.log(` Scope: ${scope.describe()}`);
|
|
17
|
+
const tasks = [];
|
|
18
|
+
let filesChanged = 0;
|
|
19
|
+
scope.visit(tree, (filePath) => {
|
|
20
|
+
if (!filePath.endsWith('.ts') || filePath.endsWith('.d.ts'))
|
|
21
|
+
return;
|
|
22
|
+
const before = tree.read(filePath, 'utf-8');
|
|
23
|
+
if (!before)
|
|
24
|
+
return;
|
|
25
|
+
const result = migrateCreateDestroyInFile(filePath, before);
|
|
26
|
+
tasks.push(...result.tasks);
|
|
27
|
+
if (result.changed) {
|
|
28
|
+
tree.write(filePath, result.content);
|
|
29
|
+
filesChanged++;
|
|
30
|
+
console.log(` ✓ ${filePath}`);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
if (tasks.length > 0) {
|
|
34
|
+
tree.write(CREATE_DESTROY_REPORT_PATH, renderReport(tasks));
|
|
35
|
+
for (const task of tasks) {
|
|
36
|
+
console.warn(` ⚠️ ${task.file}:${task.line} ${task.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!schema.skipFormat) {
|
|
40
|
+
await formatFiles(tree);
|
|
41
|
+
}
|
|
42
|
+
console.log(`\n✅ Rewrote ${filesChanged} file(s).`);
|
|
43
|
+
if (tasks.length > 0) {
|
|
44
|
+
console.log(`⚠️ ${tasks.length} site(s) need a manual decision - see ${CREATE_DESTROY_REPORT_PATH}.`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"$id": "migrate-create-destroy",
|
|
4
|
+
"title": "Migrate createDestroy to takeUntilDestroyed",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"projects": {
|
|
8
|
+
"type": "array",
|
|
9
|
+
"items": { "type": "string" },
|
|
10
|
+
"description": "Nx projects to migrate. Defaults to every project in the workspace. Every class is rewritten independently, so any scope is safe.",
|
|
11
|
+
"alias": "project"
|
|
12
|
+
},
|
|
13
|
+
"include": {
|
|
14
|
+
"type": "array",
|
|
15
|
+
"items": { "type": "string" },
|
|
16
|
+
"description": "Path prefixes to migrate, relative to the workspace root (e.g. 'libs/ui'). Unioned with `projects`. Defaults to the whole workspace.",
|
|
17
|
+
"alias": "includePath"
|
|
18
|
+
},
|
|
19
|
+
"skipFormat": {
|
|
20
|
+
"type": "boolean",
|
|
21
|
+
"description": "Skip formatting files after migration",
|
|
22
|
+
"default": false
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"required": []
|
|
26
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Node, Project, SyntaxKind } from 'ts-morph';
|
|
2
|
+
const CORE_PACKAGE = '@ethlete/core';
|
|
3
|
+
const LEGACY_PROVIDER = 'provideColorThemes';
|
|
4
|
+
const TAILWIND_4_PROVIDER = 'provideColorThemesWithTailwind4';
|
|
5
|
+
const SURFACE_PROVIDER = 'provideSurfaceThemesWithTailwind4';
|
|
6
|
+
const TAILWIND_3_HELPERS = ['createTailwindColorThemes', 'createTailwindCssVar', 'createTailwindRgbVar'];
|
|
7
|
+
const createProject = () => new Project({
|
|
8
|
+
useInMemoryFileSystem: true,
|
|
9
|
+
compilerOptions: {
|
|
10
|
+
target: 99,
|
|
11
|
+
module: 99,
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
const coreImports = (sourceFile) => sourceFile.getImportDeclarations().filter((declaration) => declaration.getModuleSpecifierValue() === CORE_PACKAGE);
|
|
15
|
+
const namespaceNames = (imports) => imports.flatMap((declaration) => {
|
|
16
|
+
const namespace = declaration.getNamespaceImport();
|
|
17
|
+
return namespace ? [namespace.getText()] : [];
|
|
18
|
+
});
|
|
19
|
+
const isNamespaceMember = (identifier, namespaces) => {
|
|
20
|
+
const parent = identifier.getParent();
|
|
21
|
+
return (Node.isPropertyAccessExpression(parent) &&
|
|
22
|
+
parent.getNameNode() === identifier &&
|
|
23
|
+
namespaces.includes(parent.getExpression().getText()));
|
|
24
|
+
};
|
|
25
|
+
const isBareReference = (identifier) => {
|
|
26
|
+
const parent = identifier.getParent();
|
|
27
|
+
if (Node.isImportSpecifier(parent))
|
|
28
|
+
return false;
|
|
29
|
+
if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === identifier)
|
|
30
|
+
return false;
|
|
31
|
+
if (Node.isPropertyAssignment(parent) && parent.getNameNode() === identifier)
|
|
32
|
+
return false;
|
|
33
|
+
if (Node.isPropertyDeclaration(parent) || Node.isMethodDeclaration(parent))
|
|
34
|
+
return false;
|
|
35
|
+
return true;
|
|
36
|
+
};
|
|
37
|
+
const localNameOf = (specifier) => (specifier.getAliasNode() ?? specifier.getNameNode()).getText();
|
|
38
|
+
const importSourceOf = (sourceFile, name) => sourceFile
|
|
39
|
+
.getImportDeclarations()
|
|
40
|
+
.find((declaration) => declaration.getNamedImports().some((specifier) => localNameOf(specifier) === name))
|
|
41
|
+
?.getModuleSpecifierValue();
|
|
42
|
+
const collectProviderSite = (filePath, sourceFile, identifier) => {
|
|
43
|
+
const reference = Node.isPropertyAccessExpression(identifier.getParent()) ? identifier.getParent() : identifier;
|
|
44
|
+
const call = reference.getParent();
|
|
45
|
+
const site = { file: filePath, line: identifier.getStartLineNumber() };
|
|
46
|
+
if (Node.isCallExpression(call) && call.getExpression() === reference) {
|
|
47
|
+
const [argument] = call.getArguments();
|
|
48
|
+
if (argument) {
|
|
49
|
+
site.themes = argument.getText();
|
|
50
|
+
if (Node.isIdentifier(argument))
|
|
51
|
+
site.themesFrom = importSourceOf(sourceFile, argument.getText());
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return site;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Rewrites `provideColorThemes(X)` from `@ethlete/core` into `provideColorThemesWithTailwind4(X)`, fixing the
|
|
58
|
+
* import, and reports the sites plus the Tailwind 3 helpers that still need the Tailwind 4 setup by hand.
|
|
59
|
+
*/
|
|
60
|
+
export const migrateLegacyColorThemesInFile = (filePath, content) => {
|
|
61
|
+
const unchanged = { changed: false, content, providerSites: [], helperSites: [], providesSurfaceThemes: false };
|
|
62
|
+
if (!content.includes(CORE_PACKAGE))
|
|
63
|
+
return unchanged;
|
|
64
|
+
const sourceFile = createProject().createSourceFile(filePath, content);
|
|
65
|
+
const imports = coreImports(sourceFile);
|
|
66
|
+
if (imports.length === 0)
|
|
67
|
+
return unchanged;
|
|
68
|
+
const namespaces = namespaceNames(imports);
|
|
69
|
+
const specifiers = imports.flatMap((declaration) => declaration.getNamedImports());
|
|
70
|
+
const specifierOf = (name) => specifiers.find((specifier) => specifier.getNameNode().getText() === name);
|
|
71
|
+
const importedName = (name) => {
|
|
72
|
+
const specifier = specifierOf(name);
|
|
73
|
+
return specifier ? localNameOf(specifier) : undefined;
|
|
74
|
+
};
|
|
75
|
+
const helperSites = TAILWIND_3_HELPERS.flatMap((helper) => {
|
|
76
|
+
const local = importedName(helper);
|
|
77
|
+
return sourceFile
|
|
78
|
+
.getDescendantsOfKind(SyntaxKind.Identifier)
|
|
79
|
+
.filter((identifier) => (local !== undefined && identifier.getText() === local && isBareReference(identifier)) ||
|
|
80
|
+
(identifier.getText() === helper && isNamespaceMember(identifier, namespaces)))
|
|
81
|
+
.map((identifier) => ({ file: filePath, line: identifier.getStartLineNumber(), helper }));
|
|
82
|
+
});
|
|
83
|
+
const providesSurfaceThemes = importedName(SURFACE_PROVIDER) !== undefined ||
|
|
84
|
+
sourceFile
|
|
85
|
+
.getDescendantsOfKind(SyntaxKind.Identifier)
|
|
86
|
+
.some((identifier) => identifier.getText() === SURFACE_PROVIDER && isNamespaceMember(identifier, namespaces));
|
|
87
|
+
const legacySpecifier = specifierOf(LEGACY_PROVIDER);
|
|
88
|
+
const legacyLocal = legacySpecifier ? localNameOf(legacySpecifier) : undefined;
|
|
89
|
+
const isAliased = legacySpecifier?.getAliasNode() !== undefined;
|
|
90
|
+
const references = sourceFile
|
|
91
|
+
.getDescendantsOfKind(SyntaxKind.Identifier)
|
|
92
|
+
.filter((identifier) => (identifier.getText() === legacyLocal && isBareReference(identifier)) ||
|
|
93
|
+
(identifier.getText() === LEGACY_PROVIDER && isNamespaceMember(identifier, namespaces)));
|
|
94
|
+
if (!legacySpecifier && references.length === 0) {
|
|
95
|
+
return { ...unchanged, helperSites, providesSurfaceThemes };
|
|
96
|
+
}
|
|
97
|
+
const providerSites = references.map((identifier) => collectProviderSite(filePath, sourceFile, identifier));
|
|
98
|
+
const renamed = references.filter((identifier) => !isAliased || identifier.getText() !== legacyLocal);
|
|
99
|
+
for (const identifier of renamed.reverse()) {
|
|
100
|
+
const parent = identifier.getParent();
|
|
101
|
+
if (Node.isShorthandPropertyAssignment(parent)) {
|
|
102
|
+
parent.replaceWithText(`${identifier.getText()}: ${TAILWIND_4_PROVIDER}`);
|
|
103
|
+
}
|
|
104
|
+
else if (Node.isExportSpecifier(parent) && !parent.getAliasNode()) {
|
|
105
|
+
parent.replaceWithText(`${TAILWIND_4_PROVIDER} as ${identifier.getText()}`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
identifier.replaceWithText(TAILWIND_4_PROVIDER);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (legacySpecifier) {
|
|
112
|
+
if (!isAliased && importedName(TAILWIND_4_PROVIDER) === TAILWIND_4_PROVIDER) {
|
|
113
|
+
const declaration = legacySpecifier.getImportDeclaration();
|
|
114
|
+
legacySpecifier.remove();
|
|
115
|
+
if (declaration.getNamedImports().length === 0 && !declaration.getDefaultImport())
|
|
116
|
+
declaration.remove();
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
legacySpecifier.setName(TAILWIND_4_PROVIDER);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { changed: true, content: sourceFile.getFullText(), providerSites, helperSites, providesSurfaceThemes };
|
|
123
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { formatFiles } from '@nx/devkit';
|
|
2
|
+
import { writeFileSync } from 'fs';
|
|
3
|
+
import { createMigrationScope } from '../migrate-provider-shape/migration-scope.js';
|
|
4
|
+
import { migrateLegacyColorThemesInFile, } from './legacy-color-themes.js';
|
|
5
|
+
export const LEGACY_COLOR_THEMES_REPORT_PATH = 'legacy-color-themes-migration-tasks.md';
|
|
6
|
+
export const LEGACY_COLOR_THEMES_GUIDE = 'https://ethlete-sdk-docs.web.app/core/theming#migrating-from-runtime-theming';
|
|
7
|
+
const SCAN_FILE_ENV = 'ETHLETE_SCAN_FILE';
|
|
8
|
+
const generatorCommand = (site) => site.themesFrom
|
|
9
|
+
? `nx g @ethlete/core:tailwind-4-color-theme --themesPath=<the file behind '${site.themesFrom}'>`
|
|
10
|
+
: 'nx g @ethlete/core:tailwind-4-color-theme --themesPath=<the file that exports the themes>';
|
|
11
|
+
export const renderLegacyColorThemesReport = (findings) => [
|
|
12
|
+
'# Legacy color themes migration tasks',
|
|
13
|
+
'',
|
|
14
|
+
`The codemod rewrote \`provideColorThemes(…)\` into \`provideColorThemesWithTailwind4(…)\`. The new provider no`,
|
|
15
|
+
'longer injects the theme CSS at runtime, so the steps below finish the move to Tailwind 4. The guide is',
|
|
16
|
+
`${LEGACY_COLOR_THEMES_GUIDE}.`,
|
|
17
|
+
'',
|
|
18
|
+
'## Generate and import the color theme CSS',
|
|
19
|
+
'',
|
|
20
|
+
'Run the color generator once per themes file, with the prefix the Tailwind 3 config used, and import the',
|
|
21
|
+
'generated `.css` in the global stylesheet of every app below:',
|
|
22
|
+
'',
|
|
23
|
+
...findings.providerSites.map((site) => `- ${site.file}:${site.line}${site.themes ? ` - themes \`${site.themes}\`` : ''}: \`${generatorCommand(site)}\``),
|
|
24
|
+
'',
|
|
25
|
+
...(findings.helperSites.length > 0
|
|
26
|
+
? [
|
|
27
|
+
'## Remove the Tailwind 3 color config',
|
|
28
|
+
'',
|
|
29
|
+
'These Tailwind 3 helpers build the color utilities the generated CSS now emits through `@theme`. Delete',
|
|
30
|
+
'the color block and move the config to Tailwind 4:',
|
|
31
|
+
'',
|
|
32
|
+
...findings.helperSites.map((site) => `- ${site.file}:${site.line} - \`${site.helper}\``),
|
|
33
|
+
'',
|
|
34
|
+
]
|
|
35
|
+
: []),
|
|
36
|
+
...(findings.providesSurfaceThemes
|
|
37
|
+
? []
|
|
38
|
+
: [
|
|
39
|
+
'## Add surface themes',
|
|
40
|
+
'',
|
|
41
|
+
'No app in scope calls `provideSurfaceThemesWithTailwind4()`, and every component reads its backgrounds,',
|
|
42
|
+
'text and borders from a surface theme. Build a first set from the app’s existing colors: one `light`',
|
|
43
|
+
'surface (`isDefault: true`, elevation 0) from the page background, text, muted text and border colors,',
|
|
44
|
+
'and a `light-elevated` one for cards and overlays; add `dark` surfaces only if the app has a dark mode.',
|
|
45
|
+
'Generate them with `nx g @ethlete/core:tailwind-4-surface-theme`, import the generated `.css`, and',
|
|
46
|
+
'register them next to the color themes with `provideSurfaceThemesWithTailwind4(SURFACE_THEMES)`.',
|
|
47
|
+
'',
|
|
48
|
+
]),
|
|
49
|
+
'## Check the app’s own CSS',
|
|
50
|
+
'',
|
|
51
|
+
'`--et-color-primary` and its states keep working. `--et-color-alt-*` is gone: put the region in its own',
|
|
52
|
+
'`etProvideColor` scope and read `--et-color-primary` there.',
|
|
53
|
+
'',
|
|
54
|
+
].join('\n');
|
|
55
|
+
export default async function migrateLegacyColorThemes(tree, schema) {
|
|
56
|
+
console.log('\n🔄 Migrating provideColorThemes to provideColorThemesWithTailwind4...');
|
|
57
|
+
const scope = createMigrationScope(tree, schema);
|
|
58
|
+
console.log(` Scope: ${scope.describe()}`);
|
|
59
|
+
const findings = { providerSites: [], helperSites: [], providesSurfaceThemes: false };
|
|
60
|
+
const rewrites = new Map();
|
|
61
|
+
scope.visit(tree, (filePath) => {
|
|
62
|
+
if (!/\.[cm]?[jt]s$/.test(filePath) || filePath.endsWith('.d.ts'))
|
|
63
|
+
return;
|
|
64
|
+
const before = tree.read(filePath, 'utf-8');
|
|
65
|
+
if (!before)
|
|
66
|
+
return;
|
|
67
|
+
const result = migrateLegacyColorThemesInFile(filePath, before);
|
|
68
|
+
findings.providerSites.push(...result.providerSites);
|
|
69
|
+
findings.helperSites.push(...result.helperSites);
|
|
70
|
+
findings.providesSurfaceThemes ||= result.providesSurfaceThemes;
|
|
71
|
+
if (result.changed)
|
|
72
|
+
rewrites.set(filePath, result.content);
|
|
73
|
+
});
|
|
74
|
+
const scanFile = process.env[SCAN_FILE_ENV];
|
|
75
|
+
if (scanFile) {
|
|
76
|
+
writeFileSync(scanFile, JSON.stringify([...rewrites.keys()].sort()), 'utf8');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (rewrites.size === 0) {
|
|
80
|
+
console.log(' Nothing to migrate.\n');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
for (const [filePath, content] of rewrites) {
|
|
84
|
+
tree.write(filePath, content);
|
|
85
|
+
console.log(` ✓ ${filePath}`);
|
|
86
|
+
}
|
|
87
|
+
tree.write(LEGACY_COLOR_THEMES_REPORT_PATH, renderLegacyColorThemesReport(findings));
|
|
88
|
+
if (!schema.skipFormat) {
|
|
89
|
+
await formatFiles(tree);
|
|
90
|
+
}
|
|
91
|
+
console.log(`\n✅ Rewrote ${rewrites.size} file(s).`);
|
|
92
|
+
console.log(`⚠️ The Tailwind 4 setup is not done yet - see ${LEGACY_COLOR_THEMES_REPORT_PATH}.`);
|
|
93
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"$id": "migrate-legacy-color-themes",
|
|
4
|
+
"title": "Migrate provideColorThemes to provideColorThemesWithTailwind4",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"projects": {
|
|
8
|
+
"type": "array",
|
|
9
|
+
"items": { "type": "string" },
|
|
10
|
+
"description": "Nx projects to migrate. Defaults to every project in the workspace.",
|
|
11
|
+
"alias": "project"
|
|
12
|
+
},
|
|
13
|
+
"include": {
|
|
14
|
+
"type": "array",
|
|
15
|
+
"items": { "type": "string" },
|
|
16
|
+
"description": "Path prefixes to migrate, relative to the workspace root (e.g. 'apps/web'). Unioned with `projects`. Defaults to the whole workspace.",
|
|
17
|
+
"alias": "includePath"
|
|
18
|
+
},
|
|
19
|
+
"skipFormat": {
|
|
20
|
+
"type": "boolean",
|
|
21
|
+
"description": "Skip formatting files after migration",
|
|
22
|
+
"default": false
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"required": []
|
|
26
|
+
}
|