@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,122 @@
|
|
|
1
|
+
import { formatFiles, joinPathFragments, logger, readProjectConfiguration, updateProjectConfiguration, visitNotIgnoredFiles, } from '@nx/devkit';
|
|
2
|
+
const SCRIPT_PATH = 'tools/generate-build-info.cjs';
|
|
3
|
+
const SCRIPT = `const { execSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const [, , outputPath, packageJsonPath] = process.argv;
|
|
8
|
+
|
|
9
|
+
const read = (command) => {
|
|
10
|
+
try {
|
|
11
|
+
return execSync(command, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const version = fs.existsSync(packageJsonPath)
|
|
18
|
+
? JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')).version
|
|
19
|
+
: null;
|
|
20
|
+
|
|
21
|
+
const info = {
|
|
22
|
+
version: version ?? 'unknown',
|
|
23
|
+
sha: read('git rev-parse --short HEAD') ?? 'unknown',
|
|
24
|
+
branch: read('git rev-parse --abbrev-ref HEAD') ?? 'unknown',
|
|
25
|
+
builtAt: new Date().toISOString(),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const contents =
|
|
29
|
+
'// Generated by ' +
|
|
30
|
+
${JSON.stringify(SCRIPT_PATH)} +
|
|
31
|
+
' - do not edit.\\n\\nexport const APP_BUILD_INFO = ' +
|
|
32
|
+
JSON.stringify(info, null, 2) +
|
|
33
|
+
' as const;\\n';
|
|
34
|
+
|
|
35
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
36
|
+
fs.writeFileSync(outputPath, contents);
|
|
37
|
+
`;
|
|
38
|
+
const PLACEHOLDER = `// Generated by ${SCRIPT_PATH} - do not edit.
|
|
39
|
+
|
|
40
|
+
export const APP_BUILD_INFO = {
|
|
41
|
+
version: 'unknown',
|
|
42
|
+
sha: 'unknown',
|
|
43
|
+
branch: 'unknown',
|
|
44
|
+
builtAt: '1970-01-01T00:00:00.000Z',
|
|
45
|
+
} as const;
|
|
46
|
+
`;
|
|
47
|
+
const relativeImport = (from, to) => {
|
|
48
|
+
const fromParts = from.split('/').slice(0, -1);
|
|
49
|
+
const toParts = to.replace(/\.ts$/, '').split('/');
|
|
50
|
+
while (fromParts.length && toParts.length && fromParts[0] === toParts[0]) {
|
|
51
|
+
fromParts.shift();
|
|
52
|
+
toParts.shift();
|
|
53
|
+
}
|
|
54
|
+
const up = fromParts.length ? fromParts.map(() => '..') : ['.'];
|
|
55
|
+
return [...up, ...toParts].join('/');
|
|
56
|
+
};
|
|
57
|
+
/** Wires the `provideQueryDevtools({ about })` call up to the generated constant, if it can be found. */
|
|
58
|
+
const wireProvider = (tree, projectRoot, outputPath) => {
|
|
59
|
+
const candidates = [];
|
|
60
|
+
visitNotIgnoredFiles(tree, projectRoot, (file) => {
|
|
61
|
+
if (!file.endsWith('.ts') ||
|
|
62
|
+
file.endsWith('.spec.ts') ||
|
|
63
|
+
file.endsWith('.test.ts') ||
|
|
64
|
+
file.endsWith('.stories.ts')) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const contents = tree.read(file, 'utf-8');
|
|
68
|
+
if (!contents?.includes('provideQueryDevtools('))
|
|
69
|
+
return;
|
|
70
|
+
candidates.push({ file, contents });
|
|
71
|
+
});
|
|
72
|
+
const alreadyWired = candidates.some(({ contents }) => /provideQueryDevtools\(\s*{[\s\S]*?about\s*:\s*APP_BUILD_INFO/.test(contents));
|
|
73
|
+
if (alreadyWired)
|
|
74
|
+
return true;
|
|
75
|
+
const candidate = candidates.find(({ contents }) => contents.includes('provideQueryDevtools()'));
|
|
76
|
+
if (!candidate) {
|
|
77
|
+
for (const { file } of candidates) {
|
|
78
|
+
logger.warn(`${file} already passes options to provideQueryDevtools() - add \`about: APP_BUILD_INFO\` to them yourself.`);
|
|
79
|
+
}
|
|
80
|
+
return candidates.length > 0;
|
|
81
|
+
}
|
|
82
|
+
const importPath = relativeImport(candidate.file, outputPath);
|
|
83
|
+
tree.write(candidate.file, `import { APP_BUILD_INFO } from '${importPath}';\n` +
|
|
84
|
+
candidate.contents.replace('provideQueryDevtools()', 'provideQueryDevtools({ about: APP_BUILD_INFO })'));
|
|
85
|
+
return true;
|
|
86
|
+
};
|
|
87
|
+
export const devtoolsAboutGenerator = async (tree, options) => {
|
|
88
|
+
const project = readProjectConfiguration(tree, options.project);
|
|
89
|
+
const outputPath = options.outputPath ?? joinPathFragments(project.sourceRoot ?? project.root, 'build-info.ts');
|
|
90
|
+
if (!tree.exists(SCRIPT_PATH))
|
|
91
|
+
tree.write(SCRIPT_PATH, SCRIPT);
|
|
92
|
+
if (!tree.exists(outputPath))
|
|
93
|
+
tree.write(outputPath, PLACEHOLDER);
|
|
94
|
+
const gitignore = tree.read('.gitignore', 'utf-8') ?? '';
|
|
95
|
+
// Regenerated on every build, and its SHA changes with every commit - committing it would put a
|
|
96
|
+
// one-line diff in every commit that says nothing about the change.
|
|
97
|
+
if (!gitignore.includes(outputPath)) {
|
|
98
|
+
tree.write('.gitignore', `${gitignore.trimEnd()}${gitignore.trim() ? '\n' : ''}${outputPath}\n`);
|
|
99
|
+
}
|
|
100
|
+
project.targets ??= {};
|
|
101
|
+
project.targets['build-info'] = {
|
|
102
|
+
executor: 'nx:run-commands',
|
|
103
|
+
options: {
|
|
104
|
+
commands: [`node ${SCRIPT_PATH} ${outputPath} ${joinPathFragments(project.root, 'package.json')}`],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
for (const target of ['build', 'serve']) {
|
|
108
|
+
const config = project.targets[target];
|
|
109
|
+
if (!config)
|
|
110
|
+
continue;
|
|
111
|
+
config.dependsOn ??= [];
|
|
112
|
+
if (!config.dependsOn.includes('build-info'))
|
|
113
|
+
config.dependsOn.push('build-info');
|
|
114
|
+
}
|
|
115
|
+
updateProjectConfiguration(tree, options.project, project);
|
|
116
|
+
if (!wireProvider(tree, project.root, outputPath)) {
|
|
117
|
+
logger.warn(`No provideQueryDevtools() call found in ${project.root} - pass \`about: APP_BUILD_INFO\` to it yourself.`);
|
|
118
|
+
}
|
|
119
|
+
if (!options.skipFormat)
|
|
120
|
+
await formatFiles(tree);
|
|
121
|
+
};
|
|
122
|
+
export default devtoolsAboutGenerator;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"$id": "devtools-about",
|
|
4
|
+
"title": "Query devtools About generator",
|
|
5
|
+
"description": "Generates the application's build info (version, commit SHA, branch, build time) and wires it into provideQueryDevtools({ about })",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"project": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "The application to wire up",
|
|
11
|
+
"$default": { "$source": "argv", "index": 0 },
|
|
12
|
+
"x-prompt": "Which application should report its build info?"
|
|
13
|
+
},
|
|
14
|
+
"outputPath": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "Where to write the generated build-info file. Defaults to `<sourceRoot>/build-info.ts`."
|
|
17
|
+
},
|
|
18
|
+
"skipFormat": {
|
|
19
|
+
"type": "boolean",
|
|
20
|
+
"description": "Skip formatting files after generation",
|
|
21
|
+
"default": false
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"required": ["project"]
|
|
25
|
+
}
|
|
@@ -1,14 +1,35 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/nx-generators",
|
|
2
3
|
"generators": {
|
|
3
4
|
"migrate-to-v5": {
|
|
4
|
-
"
|
|
5
|
+
"implementation": "./migrate-to-v5/migration",
|
|
5
6
|
"schema": "./migrate-to-v5/schema.json",
|
|
6
7
|
"description": "Migrate to v5"
|
|
7
8
|
},
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"schema": "./
|
|
11
|
-
"description": "
|
|
9
|
+
"migrate-provider-shape": {
|
|
10
|
+
"implementation": "./migrate-provider-shape/migration",
|
|
11
|
+
"schema": "./migrate-provider-shape/schema.json",
|
|
12
|
+
"description": "Rewrite destructured provider tuples into the tree-shakeable definition shape"
|
|
13
|
+
},
|
|
14
|
+
"migrate-surface-interaction-swatch": {
|
|
15
|
+
"implementation": "./migrate-surface-interaction-swatch/migration",
|
|
16
|
+
"schema": "./migrate-surface-interaction-swatch/schema.json",
|
|
17
|
+
"description": "Rewrite surface theme interactionColor maps into the { color, onColor, inkColor } swatch shape"
|
|
18
|
+
},
|
|
19
|
+
"tailwind-4-color-theme": {
|
|
20
|
+
"implementation": "./tailwind-4-color-theme/generator",
|
|
21
|
+
"schema": "./tailwind-4-color-theme/schema.json",
|
|
22
|
+
"description": "Generate color theming for Tailwind CSS v4"
|
|
23
|
+
},
|
|
24
|
+
"tailwind-4-surface-theme": {
|
|
25
|
+
"implementation": "./tailwind-4-surface-theme/generator",
|
|
26
|
+
"schema": "./tailwind-4-surface-theme/schema.json",
|
|
27
|
+
"description": "Generate surface theming for Tailwind CSS v4"
|
|
28
|
+
},
|
|
29
|
+
"devtools-about": {
|
|
30
|
+
"implementation": "./devtools-about/generator",
|
|
31
|
+
"schema": "./devtools-about/schema.json",
|
|
32
|
+
"description": "Generate the app's build info and wire it into provideQueryDevtools({ about })"
|
|
12
33
|
}
|
|
13
34
|
}
|
|
14
35
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getProjects, visitNotIgnoredFiles } from '@nx/devkit';
|
|
2
|
+
const stripTrailingSlash = (value) => (value.endsWith('/') ? value.slice(0, -1) : value);
|
|
3
|
+
/**
|
|
4
|
+
* Limits the migration to a set of projects or paths. Unlike the query v3 migration there is no
|
|
5
|
+
* cross-file coupling here - every provider declaration is rewritten on its own - so any scope is safe;
|
|
6
|
+
* this exists to keep the diff reviewable.
|
|
7
|
+
*/
|
|
8
|
+
export const createMigrationScope = (tree, options) => {
|
|
9
|
+
const roots = [];
|
|
10
|
+
for (const projectName of options.projects ?? []) {
|
|
11
|
+
const project = getProjects(tree).get(projectName);
|
|
12
|
+
if (!project) {
|
|
13
|
+
throw new Error(`Unknown project "${projectName}". Pass names as they appear in \`nx show projects\`, or use --include with a path prefix.`);
|
|
14
|
+
}
|
|
15
|
+
roots.push(stripTrailingSlash(project.root));
|
|
16
|
+
}
|
|
17
|
+
for (const includePath of options.include ?? []) {
|
|
18
|
+
roots.push(stripTrailingSlash(includePath));
|
|
19
|
+
}
|
|
20
|
+
if (roots.length === 0) {
|
|
21
|
+
return {
|
|
22
|
+
visit: (targetTree, callback) => visitNotIgnoredFiles(targetTree, '', callback),
|
|
23
|
+
describe: () => 'the whole workspace',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const uniqueRoots = [...new Set(roots)].filter((root) => !roots.some((candidate) => candidate !== root && (root === candidate || root.startsWith(`${candidate}/`))));
|
|
27
|
+
return {
|
|
28
|
+
visit: (targetTree, callback) => {
|
|
29
|
+
for (const root of uniqueRoots) {
|
|
30
|
+
visitNotIgnoredFiles(targetTree, root, callback);
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
describe: () => uniqueRoots.join(', '),
|
|
34
|
+
};
|
|
35
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { formatFiles } from '@nx/devkit';
|
|
2
|
+
import { createMigrationScope } from './migration-scope.js';
|
|
3
|
+
import { migrateProviderShapeInFile } from './provider-shape.js';
|
|
4
|
+
export const PROVIDER_SHAPE_REPORT_PATH = 'provider-shape-migration-tasks.md';
|
|
5
|
+
const renderReport = (tasks) => [
|
|
6
|
+
'# Provider-shape migration tasks',
|
|
7
|
+
'',
|
|
8
|
+
'The codemod rewrote every destructured provider declaration into a definition plus one extractor per',
|
|
9
|
+
'binding. The sites below are **not** plain module-scope declarations, so they were left alone - each',
|
|
10
|
+
'needs a decision a codemod cannot make.',
|
|
11
|
+
'',
|
|
12
|
+
'Recipes:',
|
|
13
|
+
'',
|
|
14
|
+
'- A factory of your own that returned a `[provide, inject, token]` tuple: return the definition from',
|
|
15
|
+
' `define*Provider(…)` directly, and let its callers use the extractors.',
|
|
16
|
+
'- A call inside a function: read `.provide` / `.inject` / `.token` off the definition.',
|
|
17
|
+
'',
|
|
18
|
+
...tasks.map((task) => [`## \`${task.id}\``, '', `- ${task.file}:${task.line}`, `- ${task.message}`, ''].join('\n')),
|
|
19
|
+
].join('\n');
|
|
20
|
+
export default async function migrateProviderShape(tree, schema) {
|
|
21
|
+
console.log('\n🔄 Migrating provider tuples to the definition shape...');
|
|
22
|
+
const scope = createMigrationScope(tree, schema);
|
|
23
|
+
console.log(` Scope: ${scope.describe()}`);
|
|
24
|
+
const tasks = [];
|
|
25
|
+
let filesChanged = 0;
|
|
26
|
+
scope.visit(tree, (filePath) => {
|
|
27
|
+
if (!filePath.endsWith('.ts') || filePath.endsWith('.d.ts'))
|
|
28
|
+
return;
|
|
29
|
+
const before = tree.read(filePath, 'utf-8');
|
|
30
|
+
if (!before)
|
|
31
|
+
return;
|
|
32
|
+
const result = migrateProviderShapeInFile(filePath, before);
|
|
33
|
+
tasks.push(...result.tasks);
|
|
34
|
+
if (result.changed) {
|
|
35
|
+
tree.write(filePath, result.content);
|
|
36
|
+
filesChanged++;
|
|
37
|
+
console.log(` ✓ ${filePath}`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
if (tasks.length > 0) {
|
|
41
|
+
tree.write(PROVIDER_SHAPE_REPORT_PATH, renderReport(tasks));
|
|
42
|
+
}
|
|
43
|
+
if (!schema.skipFormat) {
|
|
44
|
+
await formatFiles(tree);
|
|
45
|
+
}
|
|
46
|
+
console.log(`\n✅ Rewrote ${filesChanged} file(s).`);
|
|
47
|
+
if (tasks.length > 0) {
|
|
48
|
+
console.log(`⚠️ ${tasks.length} site(s) need a manual decision - see ${PROVIDER_SHAPE_REPORT_PATH}.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
/**
|
|
3
|
+
* `export const [provideX, injectX] = createRootProvider(…)` is undroppable by construction: array
|
|
4
|
+
* destructuring invokes the iterator protocol, so no bundler will remove the statement, and everything
|
|
5
|
+
* the factory's closure names is retained with it. The definition shape is droppable per binding.
|
|
6
|
+
*
|
|
7
|
+
* Two families need rewriting. The `create*Provider` / `createLabels` helpers were replaced by
|
|
8
|
+
* `define*`, and their declarations take a pure annotation because they sit in library source. The
|
|
9
|
+
* runtime factories below kept their names - only their return shape changed from tuple to definition -
|
|
10
|
+
* and their call sites are application code, where an annotation would be noise.
|
|
11
|
+
*/
|
|
12
|
+
const FACTORY_TO_DEFINE = {
|
|
13
|
+
createProvider: 'defineProvider',
|
|
14
|
+
createRootProvider: 'defineRootProvider',
|
|
15
|
+
createStaticProvider: 'defineStaticProvider',
|
|
16
|
+
createStaticRootProvider: 'defineStaticRootProvider',
|
|
17
|
+
createLabels: 'defineLabels',
|
|
18
|
+
};
|
|
19
|
+
const REF_FACTORIES = new Set([
|
|
20
|
+
'createQueryClient',
|
|
21
|
+
'createBearerAuthProvider',
|
|
22
|
+
'createWebSocketClient',
|
|
23
|
+
'createQueryContext',
|
|
24
|
+
]);
|
|
25
|
+
const isKnownFactory = (name) => FACTORY_TO_DEFINE[name] !== undefined || REF_FACTORIES.has(name);
|
|
26
|
+
/** Which extractor names the three tuple positions become. */
|
|
27
|
+
const POSITION_TO_EXTRACTOR = ['toProvideFn', 'toInjectFn', 'toToken'];
|
|
28
|
+
const PURE = '/* @__PURE__ */';
|
|
29
|
+
/** `injectFooBar` / `provideFooBar` / `FOO_BAR_TOKEN` → `FOO_BAR`. */
|
|
30
|
+
const toDefinitionBaseName = (bindingName) => {
|
|
31
|
+
const withoutPrefix = bindingName
|
|
32
|
+
.replace(/^ɵ/, '')
|
|
33
|
+
.replace(/^(provide|inject)/i, '')
|
|
34
|
+
.replace(/_TOKEN$/, '');
|
|
35
|
+
const screaming = withoutPrefix
|
|
36
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
37
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
38
|
+
.toUpperCase();
|
|
39
|
+
return screaming.replace(/^_+|_+$/g, '') || 'PROVIDER';
|
|
40
|
+
};
|
|
41
|
+
const uniqueDefinitionName = (bindingNames, taken) => {
|
|
42
|
+
const base = `${toDefinitionBaseName(bindingNames[1] ?? bindingNames[0] ?? bindingNames[2] ?? 'provider')}_DEF`;
|
|
43
|
+
let candidate = base;
|
|
44
|
+
let suffix = 2;
|
|
45
|
+
while (taken.has(candidate)) {
|
|
46
|
+
candidate = `${base}_${suffix++}`;
|
|
47
|
+
}
|
|
48
|
+
taken.add(candidate);
|
|
49
|
+
return candidate;
|
|
50
|
+
};
|
|
51
|
+
const collectImportedFactories = (sourceFile) => {
|
|
52
|
+
const importedFactories = new Map();
|
|
53
|
+
for (const statement of sourceFile.statements) {
|
|
54
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
55
|
+
continue;
|
|
56
|
+
if (!statement.moduleSpecifier.text.startsWith('@ethlete/'))
|
|
57
|
+
continue;
|
|
58
|
+
const bindings = statement.importClause?.namedBindings;
|
|
59
|
+
if (!bindings || !ts.isNamedImports(bindings))
|
|
60
|
+
continue;
|
|
61
|
+
for (const element of bindings.elements) {
|
|
62
|
+
const importedName = element.propertyName?.text ?? element.name.text;
|
|
63
|
+
if (isKnownFactory(importedName))
|
|
64
|
+
importedFactories.set(element.name.text, importedName);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return importedFactories;
|
|
68
|
+
};
|
|
69
|
+
const findTupleSites = (sourceFile) => {
|
|
70
|
+
const sites = [];
|
|
71
|
+
const importedFactories = collectImportedFactories(sourceFile);
|
|
72
|
+
for (const statement of sourceFile.statements) {
|
|
73
|
+
if (!ts.isVariableStatement(statement) || statement.declarationList.declarations.length !== 1)
|
|
74
|
+
continue;
|
|
75
|
+
const declaration = statement.declarationList.declarations[0];
|
|
76
|
+
if (!declaration || !ts.isArrayBindingPattern(declaration.name))
|
|
77
|
+
continue;
|
|
78
|
+
const call = declaration.initializer;
|
|
79
|
+
if (!call || !ts.isCallExpression(call) || !ts.isIdentifier(call.expression))
|
|
80
|
+
continue;
|
|
81
|
+
const factory = importedFactories.get(call.expression.text);
|
|
82
|
+
if (!factory)
|
|
83
|
+
continue;
|
|
84
|
+
const bindings = declaration.name.elements.map((element) => {
|
|
85
|
+
if (ts.isOmittedExpression(element) || !ts.isIdentifier(element.name))
|
|
86
|
+
return null;
|
|
87
|
+
return element.name.text;
|
|
88
|
+
});
|
|
89
|
+
if (bindings.length > POSITION_TO_EXTRACTOR.length)
|
|
90
|
+
continue;
|
|
91
|
+
sites.push({
|
|
92
|
+
statement,
|
|
93
|
+
call,
|
|
94
|
+
factory,
|
|
95
|
+
bindings,
|
|
96
|
+
isExported: !!statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return sites;
|
|
100
|
+
};
|
|
101
|
+
/** The offset the replacement starts at: before the declaration's own JSDoc, if it has one. */
|
|
102
|
+
const declarationStart = (content, statement, sourceFile) => {
|
|
103
|
+
const start = statement.getStart(sourceFile);
|
|
104
|
+
const comments = ts.getLeadingCommentRanges(content, statement.getFullStart());
|
|
105
|
+
const first = comments?.[0];
|
|
106
|
+
// Only treat comments as the declaration's own when nothing but whitespace separates them from it.
|
|
107
|
+
if (!first || /\S/.test(content.slice(comments[comments.length - 1].end, start)))
|
|
108
|
+
return start;
|
|
109
|
+
return first.pos;
|
|
110
|
+
};
|
|
111
|
+
const rewriteSite = (content, sourceFile, site, definitionName) => {
|
|
112
|
+
const start = declarationStart(content, site.statement, sourceFile);
|
|
113
|
+
const statementStart = site.statement.getStart(sourceFile);
|
|
114
|
+
const doc = content.slice(start, statementStart);
|
|
115
|
+
const replacement = FACTORY_TO_DEFINE[site.factory];
|
|
116
|
+
const annotation = replacement ? `${PURE} ` : '';
|
|
117
|
+
const callTail = content.slice(site.call.expression.getEnd(), site.call.getEnd());
|
|
118
|
+
const definition = `const ${definitionName} = ${annotation}${replacement ?? site.factory}${callTail};`;
|
|
119
|
+
const exportKeyword = site.isExported ? 'export ' : '';
|
|
120
|
+
const extractions = site.bindings
|
|
121
|
+
.map((binding, index) => binding === null
|
|
122
|
+
? null
|
|
123
|
+
: `${exportKeyword}const ${binding} = ${annotation}${POSITION_TO_EXTRACTOR[index]}(${definitionName});`)
|
|
124
|
+
.filter((line) => line !== null);
|
|
125
|
+
// The JSDoc documented the pair, so it belongs on the first binding a consumer can reach.
|
|
126
|
+
const documented = extractions.length > 0 ? [definition, '', doc + extractions[0], ...extractions.slice(1)] : [doc + definition];
|
|
127
|
+
return { start, end: site.statement.getEnd(), text: documented.join('\n') };
|
|
128
|
+
};
|
|
129
|
+
const rewriteImports = (sourceFile, factoriesUsed, extractorsUsed) => {
|
|
130
|
+
const rewrites = [];
|
|
131
|
+
const imports = sourceFile.statements.filter(ts.isImportDeclaration);
|
|
132
|
+
const replacementsUsed = new Set([...factoriesUsed].flatMap((factory) => (FACTORY_TO_DEFINE[factory] ? [FACTORY_TO_DEFINE[factory]] : [])));
|
|
133
|
+
const extractorHost = imports.find((statement) => namedImportsOf(statement, sourceFile)?.some((name) => extractorsUsed.has(name))) ??
|
|
134
|
+
imports.find(isCoreImport);
|
|
135
|
+
for (const statement of imports) {
|
|
136
|
+
const bindings = statement.importClause?.namedBindings;
|
|
137
|
+
if (!bindings || !ts.isNamedImports(bindings))
|
|
138
|
+
continue;
|
|
139
|
+
const elements = bindings.elements.map((element) => ({
|
|
140
|
+
text: element.getText(sourceFile),
|
|
141
|
+
importedName: element.propertyName?.text ?? element.name.text,
|
|
142
|
+
}));
|
|
143
|
+
const replaced = elements.filter((element) => factoriesUsed.has(element.importedName) && FACTORY_TO_DEFINE[element.importedName] !== undefined);
|
|
144
|
+
const additions = statement === extractorHost ? [...replacementsUsed, ...extractorsUsed] : [];
|
|
145
|
+
if (replaced.length === 0 && additions.length === 0)
|
|
146
|
+
continue;
|
|
147
|
+
const kept = elements.filter((element) => !replaced.includes(element)).map((element) => element.text);
|
|
148
|
+
const merged = [...new Set([...kept, ...additions])].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
149
|
+
rewrites.push({
|
|
150
|
+
start: bindings.getStart(sourceFile),
|
|
151
|
+
end: bindings.getEnd(),
|
|
152
|
+
text: `{ ${merged.join(', ')} }`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (extractorHost)
|
|
156
|
+
return rewrites;
|
|
157
|
+
// Nothing to join - the file only imported a ref factory from another package, so add a core import.
|
|
158
|
+
const anchor = imports[imports.length - 1];
|
|
159
|
+
const position = anchor ? anchor.getEnd() : 0;
|
|
160
|
+
const statement = `import { ${[...replacementsUsed, ...extractorsUsed].sort().join(', ')} } from '@ethlete/core';`;
|
|
161
|
+
rewrites.push({ start: position, end: position, text: anchor ? `\n${statement}` : `${statement}\n` });
|
|
162
|
+
return rewrites;
|
|
163
|
+
};
|
|
164
|
+
const namedImportsOf = (statement, sourceFile) => {
|
|
165
|
+
const bindings = statement.importClause?.namedBindings;
|
|
166
|
+
if (!bindings || !ts.isNamedImports(bindings))
|
|
167
|
+
return undefined;
|
|
168
|
+
return bindings.elements.map((element) => element.getText(sourceFile));
|
|
169
|
+
};
|
|
170
|
+
const isCoreImport = (statement) => ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === '@ethlete/core';
|
|
171
|
+
/**
|
|
172
|
+
* Rewrites every top-level destructured provider tuple in one file into a descriptor plus one pure
|
|
173
|
+
* extractor per exported binding. Returns the file unchanged when there is nothing to do.
|
|
174
|
+
*/
|
|
175
|
+
export const migrateProviderShapeInFile = (filePath, content) => {
|
|
176
|
+
if (![...Object.keys(FACTORY_TO_DEFINE), ...REF_FACTORIES].some((factory) => content.includes(factory))) {
|
|
177
|
+
return { content, changed: false, tasks: [] };
|
|
178
|
+
}
|
|
179
|
+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
|
180
|
+
const sites = findTupleSites(sourceFile);
|
|
181
|
+
const tasks = collectTasks(filePath, sourceFile, sites);
|
|
182
|
+
if (sites.length === 0)
|
|
183
|
+
return { content, changed: false, tasks };
|
|
184
|
+
const taken = collectTopLevelNames(sourceFile);
|
|
185
|
+
const factoriesUsed = new Set(sites.map((site) => site.factory));
|
|
186
|
+
const extractorsUsed = new Set(sites.flatMap((site) => site.bindings.flatMap((binding, index) => (binding === null ? [] : [POSITION_TO_EXTRACTOR[index]]))));
|
|
187
|
+
const edits = [
|
|
188
|
+
...sites.map((site) => rewriteSite(content, sourceFile, site, uniqueDefinitionName(site.bindings.filter((b) => b !== null), taken))),
|
|
189
|
+
...rewriteImports(sourceFile, factoriesUsed, extractorsUsed),
|
|
190
|
+
].sort((a, b) => b.start - a.start);
|
|
191
|
+
let updated = content;
|
|
192
|
+
for (const edit of edits) {
|
|
193
|
+
updated = updated.slice(0, edit.start) + edit.text + updated.slice(edit.end);
|
|
194
|
+
}
|
|
195
|
+
return { content: updated, changed: updated !== content, tasks };
|
|
196
|
+
};
|
|
197
|
+
/** Call sites the codemod cannot safely rewrite - a factory result that is not a plain tuple binding. */
|
|
198
|
+
const collectTasks = (filePath, sourceFile, sites) => {
|
|
199
|
+
const rewritten = new Set(sites.map((site) => site.statement));
|
|
200
|
+
const tasks = [];
|
|
201
|
+
const importedFactories = collectImportedFactories(sourceFile);
|
|
202
|
+
for (const statement of sourceFile.statements) {
|
|
203
|
+
if (!ts.isExportDeclaration(statement))
|
|
204
|
+
continue;
|
|
205
|
+
if (!statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
206
|
+
continue;
|
|
207
|
+
if (!statement.moduleSpecifier.text.startsWith('@ethlete/') || !statement.exportClause)
|
|
208
|
+
continue;
|
|
209
|
+
if (!ts.isNamedExports(statement.exportClause))
|
|
210
|
+
continue;
|
|
211
|
+
for (const element of statement.exportClause.elements) {
|
|
212
|
+
const exportedName = element.propertyName?.text ?? element.name.text;
|
|
213
|
+
if (!isKnownFactory(exportedName))
|
|
214
|
+
continue;
|
|
215
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(element.getStart(sourceFile));
|
|
216
|
+
tasks.push({
|
|
217
|
+
id: `provider-shape:${filePath}:${line + 1}`,
|
|
218
|
+
file: filePath,
|
|
219
|
+
line: line + 1,
|
|
220
|
+
message: `The \`${exportedName}\` re-export must be migrated manually because the replacement API returns a provider definition.`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const visit = (node) => {
|
|
225
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
226
|
+
const importedFactory = importedFactories.get(node.expression.text);
|
|
227
|
+
if (!importedFactory || FACTORY_TO_DEFINE[importedFactory] === undefined) {
|
|
228
|
+
ts.forEachChild(node, visit);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const statement = enclosingStatement(node);
|
|
232
|
+
if (!statement || !rewritten.has(statement)) {
|
|
233
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
234
|
+
const factory = importedFactory;
|
|
235
|
+
const replacement = FACTORY_TO_DEFINE[factory];
|
|
236
|
+
tasks.push({
|
|
237
|
+
id: `provider-shape:${filePath}:${line + 1}`,
|
|
238
|
+
file: filePath,
|
|
239
|
+
line: line + 1,
|
|
240
|
+
message: replacement
|
|
241
|
+
? `\`${factory}\` is gone. Replace it with \`${replacement}\` and read the halves with \`toProvideFn\` / \`toInjectFn\` / \`toToken\`.`
|
|
242
|
+
: `\`${factory}\` returns a provider definition now, not a \`[provide, inject, token]\` tuple. Read the halves with \`toProvideFn\` / \`toInjectFn\` / \`toToken\`, or \`.provide\` / \`.inject\` / \`.token\` inside a function.`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
ts.forEachChild(node, visit);
|
|
247
|
+
};
|
|
248
|
+
visit(sourceFile);
|
|
249
|
+
return tasks;
|
|
250
|
+
};
|
|
251
|
+
const collectTopLevelNames = (sourceFile) => {
|
|
252
|
+
const names = new Set();
|
|
253
|
+
for (const statement of sourceFile.statements) {
|
|
254
|
+
if (ts.isImportDeclaration(statement)) {
|
|
255
|
+
const clause = statement.importClause;
|
|
256
|
+
if (clause?.name)
|
|
257
|
+
names.add(clause.name.text);
|
|
258
|
+
const bindings = clause?.namedBindings;
|
|
259
|
+
if (bindings && ts.isNamedImports(bindings))
|
|
260
|
+
bindings.elements.forEach((element) => names.add(element.name.text));
|
|
261
|
+
if (bindings && ts.isNamespaceImport(bindings))
|
|
262
|
+
names.add(bindings.name.text);
|
|
263
|
+
}
|
|
264
|
+
else if (ts.isVariableStatement(statement)) {
|
|
265
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
266
|
+
if (ts.isIdentifier(declaration.name))
|
|
267
|
+
names.add(declaration.name.text);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else if ((ts.isClassDeclaration(statement) || ts.isFunctionDeclaration(statement)) && statement.name) {
|
|
271
|
+
names.add(statement.name.text);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return names;
|
|
275
|
+
};
|
|
276
|
+
const enclosingStatement = (node) => {
|
|
277
|
+
let current = node;
|
|
278
|
+
while (current && !ts.isSourceFile(current)) {
|
|
279
|
+
if (ts.isVariableStatement(current))
|
|
280
|
+
return current;
|
|
281
|
+
current = current.parent;
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"$id": "migrate-provider-shape",
|
|
4
|
+
"title": "Migrate provider tuples to the definition shape",
|
|
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 declaration is rewritten independently, so any scope is safe - use this to keep the diff reviewable.",
|
|
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,63 @@
|
|
|
1
|
+
import { Project, SyntaxKind } from 'ts-morph';
|
|
2
|
+
const INTERACTION_KEYS = ['default', 'hover', 'focus', 'active', 'disabled'];
|
|
3
|
+
const createProject = () => new Project({
|
|
4
|
+
useInMemoryFileSystem: true,
|
|
5
|
+
compilerOptions: {
|
|
6
|
+
target: 99,
|
|
7
|
+
module: 99,
|
|
8
|
+
},
|
|
9
|
+
});
|
|
10
|
+
const isFlatInteractionColor = (initializer) => initializer
|
|
11
|
+
.getProperties()
|
|
12
|
+
.some((property) => property.isKind(SyntaxKind.PropertyAssignment) && INTERACTION_KEYS.includes(property.getName()));
|
|
13
|
+
const findFlatInteractionColor = (sourceFile) => {
|
|
14
|
+
for (const property of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
15
|
+
if (property.getName() !== 'interactionColor')
|
|
16
|
+
continue;
|
|
17
|
+
const initializer = property.getInitializer();
|
|
18
|
+
if (initializer?.isKind(SyntaxKind.ObjectLiteralExpression) && isFlatInteractionColor(initializer)) {
|
|
19
|
+
return initializer;
|
|
20
|
+
}
|
|
21
|
+
if (initializer?.isKind(SyntaxKind.Identifier)) {
|
|
22
|
+
const declaration = sourceFile
|
|
23
|
+
.getVariableDeclarations()
|
|
24
|
+
.find((candidate) => candidate.getName() === initializer.getText());
|
|
25
|
+
const referencedInitializer = declaration?.getInitializer();
|
|
26
|
+
if (referencedInitializer?.isKind(SyntaxKind.ObjectLiteralExpression) &&
|
|
27
|
+
isFlatInteractionColor(referencedInitializer)) {
|
|
28
|
+
return referencedInitializer;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Rewrites `interactionColor: { default, hover, … }` into `interactionColor: { color: { default, … } }`,
|
|
36
|
+
* the swatch shape that also carries the optional `onColor` and `inkColor` maps.
|
|
37
|
+
*/
|
|
38
|
+
export const migrateInteractionSwatchInFile = (filePath, content) => {
|
|
39
|
+
if (!content.includes('interactionColor')) {
|
|
40
|
+
return { changed: false, content };
|
|
41
|
+
}
|
|
42
|
+
const sourceFile = createProject().createSourceFile(filePath, content);
|
|
43
|
+
let changed = false;
|
|
44
|
+
// Re-queried after every rewrite: manipulating the AST forgets the nodes a single up-front
|
|
45
|
+
// collection would have handed us.
|
|
46
|
+
for (let initializer = findFlatInteractionColor(sourceFile); initializer;) {
|
|
47
|
+
initializer.replaceWithText(`{ color: ${initializer.getText()} }`);
|
|
48
|
+
changed = true;
|
|
49
|
+
initializer = findFlatInteractionColor(sourceFile);
|
|
50
|
+
}
|
|
51
|
+
const unresolved = sourceFile
|
|
52
|
+
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
|
|
53
|
+
.filter((property) => property.getName() === 'interactionColor')
|
|
54
|
+
.flatMap((property) => {
|
|
55
|
+
const initializer = property.getInitializer();
|
|
56
|
+
return initializer && !initializer.isKind(SyntaxKind.ObjectLiteralExpression) ? [initializer.getText()] : [];
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
changed,
|
|
60
|
+
content: changed ? sourceFile.getFullText() : content,
|
|
61
|
+
...(unresolved.length > 0 ? { unresolved } : {}),
|
|
62
|
+
};
|
|
63
|
+
};
|