@triggery/codemod 0.1.0
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 +9 -0
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/cli.js +321 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +220 -0
- package/dist/index.js.map +1 -0
- package/package.json +75 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @triggery/codemod
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First public preview release.
|
|
6
|
+
|
|
7
|
+
Codemods for migrating React/Redux side-effect code to Triggery — ts-morph powered.
|
|
8
|
+
|
|
9
|
+
See the [repository-level CHANGELOG](../../CHANGELOG.md#010--2026-05-16) for the full set of packages and the umbrella feature list. Future entries on this file are appended automatically by changesets.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aleksey Skhomenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @triggery/codemod
|
|
2
|
+
|
|
3
|
+
Codemods that mechanically migrate React/Redux side-effect code to Triggery's `event → conditions → actions` model. Powered by [ts-morph](https://ts-morph.com).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add -D @triggery/codemod
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## CLI
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Pull a useEffect block out of a component into a *.trigger.ts file.
|
|
13
|
+
npx triggery-codemod extract-trigger --name new-message src/Chat.tsx
|
|
14
|
+
|
|
15
|
+
# Generate one trigger per RTK listenerMiddleware.startListening({ actionCreator, effect }).
|
|
16
|
+
npx triggery-codemod migrate-from-listener-middleware src/store/middleware.ts
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Add `--dry-run` to preview without writing.
|
|
20
|
+
|
|
21
|
+
## Programmatic API
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { extractTrigger, migrateFromListenerMiddleware } from '@triggery/codemod';
|
|
25
|
+
|
|
26
|
+
extractTrigger({ file: 'src/Chat.tsx', name: 'new-message' });
|
|
27
|
+
|
|
28
|
+
migrateFromListenerMiddleware({ file: 'src/store/middleware.ts' });
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## What each codemod does (and what it leaves to you)
|
|
32
|
+
|
|
33
|
+
### `extract-trigger`
|
|
34
|
+
|
|
35
|
+
Reads the first `useEffect(() => { … }, [])` in the file and writes a sibling `<name>.trigger.ts` containing a stub `createTrigger({…, handler() { /* original body */ } })`. The component is rewritten to call `useEvent(<name>Trigger, '<event-name>')` instead.
|
|
36
|
+
|
|
37
|
+
The codemod intentionally **does not**:
|
|
38
|
+
|
|
39
|
+
- Infer the `events` / `conditions` / `actions` schema generic — you write it.
|
|
40
|
+
- Move closure-captured state into typed conditions — refactor by hand.
|
|
41
|
+
- Touch cleanup functions or effects with conditional deps — handle manually.
|
|
42
|
+
|
|
43
|
+
### `migrate-from-listener-middleware`
|
|
44
|
+
|
|
45
|
+
For each `startListening({ actionCreator, effect })` call in the file, generates one `<event-name>.trigger.ts`. The `effect` body is dropped verbatim into the new trigger's handler with a `// TODO: refactor dispatch/getState into actions/conditions` marker.
|
|
46
|
+
|
|
47
|
+
Other listenerMiddleware shapes (`matcher`, `predicate`, `type`) are detected but skipped — they need human review.
|
|
48
|
+
|
|
49
|
+
## Why ts-morph (and not jscodeshift / babel)?
|
|
50
|
+
|
|
51
|
+
ts-morph is the TypeScript Compiler API with a nicer surface. It speaks JSX and the same type system the rest of Triggery is built on. The codemods produce TypeScript output, so type-aware AST work would be a regression with jscodeshift's recast-based round-trip.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { Project, Node, SyntaxKind } from 'ts-morph';
|
|
4
|
+
|
|
5
|
+
function slugify(input) {
|
|
6
|
+
return input.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
7
|
+
}
|
|
8
|
+
function kebabToCamel(input) {
|
|
9
|
+
return input.split("-").map((part, i) => i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
10
|
+
}
|
|
11
|
+
function isInsideHook(node) {
|
|
12
|
+
let parent = node.getParent();
|
|
13
|
+
while (parent) {
|
|
14
|
+
if (Node.isFunctionDeclaration(parent) || Node.isArrowFunction(parent) || Node.isFunctionExpression(parent) || Node.isMethodDeclaration(parent)) {
|
|
15
|
+
const name = getFunctionName(parent);
|
|
16
|
+
if (name && (/^[A-Z]/.test(name) || /^use[A-Z]/.test(name))) return true;
|
|
17
|
+
}
|
|
18
|
+
parent = parent.getParent();
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
function getFunctionName(node) {
|
|
23
|
+
if (Node.isFunctionDeclaration(node)) {
|
|
24
|
+
return node.getName() ?? null;
|
|
25
|
+
}
|
|
26
|
+
const parent = node.getParent();
|
|
27
|
+
if (!parent) return null;
|
|
28
|
+
if (Node.isVariableDeclaration(parent)) return parent.getName();
|
|
29
|
+
if (Node.isPropertyAssignment(parent)) {
|
|
30
|
+
const name = parent.getName();
|
|
31
|
+
return name;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/codemods/extract-trigger.ts
|
|
37
|
+
function extractTrigger(options) {
|
|
38
|
+
const project = options.project ?? new Project({
|
|
39
|
+
useInMemoryFileSystem: false,
|
|
40
|
+
skipFileDependencyResolution: true,
|
|
41
|
+
skipLoadingLibFiles: true
|
|
42
|
+
});
|
|
43
|
+
const source = project.addSourceFileAtPath(options.file);
|
|
44
|
+
const useEffectCall = findFirstUseEffect(source);
|
|
45
|
+
if (!useEffectCall) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`[triggery/codemod] No useEffect call found in ${options.file}. Nothing to extract.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const handler = useEffectCall.getArguments()[0];
|
|
51
|
+
if (!handler || !Node.isArrowFunction(handler) && !Node.isFunctionExpression(handler)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`[triggery/codemod] useEffect's first argument is not a function in ${options.file}.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const body = handler.getBody();
|
|
57
|
+
const bodyText = Node.isBlock(body) ? body.getText().replace(/^{\s*|\s*}$/g, "").trim() : `return ${body.getText()};`;
|
|
58
|
+
const eventName = slugify(options.name);
|
|
59
|
+
const symbolName = `${kebabToCamel(eventName)}Trigger`;
|
|
60
|
+
const triggerFilePath = join(options.outDir ?? dirname(options.file), `${eventName}.trigger.ts`);
|
|
61
|
+
const triggerContent = renderTriggerFile({ symbolName, eventName, bodyText });
|
|
62
|
+
const useEffectStatement = useEffectCall.getFirstAncestorByKind(SyntaxKind.ExpressionStatement);
|
|
63
|
+
if (useEffectStatement && isInsideHook(useEffectStatement)) {
|
|
64
|
+
useEffectStatement.replaceWithText(
|
|
65
|
+
`// Migrated to ./${eventName}.trigger.ts \u2014 fire the event instead of running the effect inline.
|
|
66
|
+
useEvent(${symbolName}, '${eventName}');`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (!options.dryRun) {
|
|
70
|
+
project.createSourceFile(triggerFilePath, triggerContent, { overwrite: true });
|
|
71
|
+
project.saveSync();
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
sourceUpdated: Boolean(useEffectStatement),
|
|
75
|
+
triggerFilePath,
|
|
76
|
+
triggerFileContent: triggerContent,
|
|
77
|
+
originalEffectBody: bodyText
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function findFirstUseEffect(source) {
|
|
81
|
+
for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
82
|
+
const expr = call.getExpression();
|
|
83
|
+
if (expr.getKind() === SyntaxKind.Identifier && expr.getText() === "useEffect") {
|
|
84
|
+
return call;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
function renderTriggerFile(opts) {
|
|
90
|
+
const indented = opts.bodyText.split("\n").map((line) => line.length === 0 ? line : ` ${line}`).join("\n");
|
|
91
|
+
return `import { createTrigger } from '@triggery/core';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extracted automatically by @triggery/codemod from a useEffect block.
|
|
95
|
+
* Review the generated handler \u2014 the codemod does its best but cannot infer
|
|
96
|
+
* the runtime "events / conditions / actions" surface without your input.
|
|
97
|
+
*
|
|
98
|
+
* Next steps:
|
|
99
|
+
* 1. Declare the proper Schema generic below.
|
|
100
|
+
* 2. Move side-effects into named \`actions.<name>\` calls; declare the
|
|
101
|
+
* actions in the generic and register them via \`useAction\` in the
|
|
102
|
+
* component(s) that own them.
|
|
103
|
+
* 3. Move read-only inputs into typed \`conditions\` and register them via
|
|
104
|
+
* \`useCondition\` instead of relying on captured closure state.
|
|
105
|
+
* 4. Delete the TODO marker once the migration is complete.
|
|
106
|
+
*/
|
|
107
|
+
export const ${opts.symbolName} = createTrigger<{
|
|
108
|
+
events: { '${opts.eventName}': void };
|
|
109
|
+
conditions: Record<string, never>;
|
|
110
|
+
actions: Record<string, never>;
|
|
111
|
+
}>({
|
|
112
|
+
id: '${opts.eventName}',
|
|
113
|
+
events: ['${opts.eventName}'],
|
|
114
|
+
required: [],
|
|
115
|
+
handler({ event, conditions, actions, check }) {
|
|
116
|
+
// TODO: migrated from useEffect \u2014 refactor side effects into actions.
|
|
117
|
+
${indented}
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
`;
|
|
121
|
+
}
|
|
122
|
+
function migrateFromListenerMiddleware(options) {
|
|
123
|
+
const project = options.project ?? new Project({
|
|
124
|
+
useInMemoryFileSystem: false,
|
|
125
|
+
skipFileDependencyResolution: true,
|
|
126
|
+
skipLoadingLibFiles: true
|
|
127
|
+
});
|
|
128
|
+
const source = project.addSourceFileAtPath(options.file);
|
|
129
|
+
const migrated = [];
|
|
130
|
+
for (const call of findStartListeningCalls(source)) {
|
|
131
|
+
const arg = call.getArguments()[0];
|
|
132
|
+
if (!arg || !Node.isObjectLiteralExpression(arg)) continue;
|
|
133
|
+
const actionCreator = readActionCreator(arg);
|
|
134
|
+
const effect = readEffectBody(arg);
|
|
135
|
+
if (!actionCreator || effect === null) continue;
|
|
136
|
+
const eventName = slugify(actionCreator);
|
|
137
|
+
const symbolName = `${kebabToCamel(eventName)}Trigger`;
|
|
138
|
+
const triggerContent = renderTriggerFile2({ symbolName, eventName, effect });
|
|
139
|
+
const triggerFilePath = join(
|
|
140
|
+
options.outDir ?? dirname(options.file),
|
|
141
|
+
`${eventName}.trigger.ts`
|
|
142
|
+
);
|
|
143
|
+
if (!options.dryRun) {
|
|
144
|
+
project.createSourceFile(triggerFilePath, triggerContent, { overwrite: true });
|
|
145
|
+
}
|
|
146
|
+
migrated.push({ eventName, triggerFilePath, triggerFileContent: triggerContent });
|
|
147
|
+
}
|
|
148
|
+
if (!options.dryRun && migrated.length > 0) project.saveSync();
|
|
149
|
+
return { file: options.file, migrated };
|
|
150
|
+
}
|
|
151
|
+
function findStartListeningCalls(source) {
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
154
|
+
const expr = call.getExpression();
|
|
155
|
+
if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
156
|
+
const text = expr.getText();
|
|
157
|
+
if (text.endsWith(".startListening")) out.push(call);
|
|
158
|
+
} else if (expr.getKind() === SyntaxKind.Identifier && expr.getText() === "startListening") {
|
|
159
|
+
out.push(call);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
function readActionCreator(arg) {
|
|
165
|
+
const prop = arg.getProperty("actionCreator");
|
|
166
|
+
if (!prop || !Node.isPropertyAssignment(prop)) return null;
|
|
167
|
+
const init = prop.getInitializer();
|
|
168
|
+
if (!init) return null;
|
|
169
|
+
return init.getText();
|
|
170
|
+
}
|
|
171
|
+
function readEffectBody(arg) {
|
|
172
|
+
const prop = arg.getProperty("effect");
|
|
173
|
+
if (!prop || !Node.isPropertyAssignment(prop)) return null;
|
|
174
|
+
const init = prop.getInitializer();
|
|
175
|
+
if (!init) return null;
|
|
176
|
+
if (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) {
|
|
177
|
+
const body = init.getBody();
|
|
178
|
+
if (Node.isBlock(body))
|
|
179
|
+
return body.getText().replace(/^{\s*|\s*}$/g, "").trim();
|
|
180
|
+
return `return ${body.getText()};`;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
function renderTriggerFile2(opts) {
|
|
185
|
+
const indented = opts.effect.split("\n").map((line) => line.length === 0 ? line : ` ${line}`).join("\n");
|
|
186
|
+
return `import { createTrigger } from '@triggery/core';
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Auto-migrated from a Redux Toolkit listenerMiddleware \`startListening\`
|
|
190
|
+
* registration. Review the generated handler \u2014 the original \`effect\` ran
|
|
191
|
+
* inside an RTK store context (listenerApi.dispatch, etc.) which Triggery
|
|
192
|
+
* does not provide directly. Recommended steps:
|
|
193
|
+
*
|
|
194
|
+
* 1. Replace \`listenerApi.dispatch(x)\` with a Triggery action:
|
|
195
|
+
* add an \`actions.<name>\` entry to the generic and call it inside the
|
|
196
|
+
* handler.
|
|
197
|
+
* 2. Replace reads of \`listenerApi.getState()\` with typed conditions.
|
|
198
|
+
* 3. Wire the new \`actions\` via \`useAction\` and \`conditions\` via
|
|
199
|
+
* \`useCondition\` in the appropriate components.
|
|
200
|
+
*/
|
|
201
|
+
export const ${opts.symbolName} = createTrigger<{
|
|
202
|
+
events: { '${opts.eventName}': unknown };
|
|
203
|
+
conditions: Record<string, never>;
|
|
204
|
+
actions: Record<string, never>;
|
|
205
|
+
}>({
|
|
206
|
+
id: '${opts.eventName}',
|
|
207
|
+
events: ['${opts.eventName}'],
|
|
208
|
+
required: [],
|
|
209
|
+
async handler({ event, conditions, actions, check }) {
|
|
210
|
+
// TODO: original RTK effect body \u2014 refactor dispatch/getState into actions/conditions.
|
|
211
|
+
const action = event.payload;
|
|
212
|
+
${indented}
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// src/cli.ts
|
|
219
|
+
var USAGE = `Usage: triggery-codemod <command> [options] <file>
|
|
220
|
+
|
|
221
|
+
Commands:
|
|
222
|
+
extract-trigger Extract a useEffect block into a *.trigger.ts file.
|
|
223
|
+
--name <kebab-case> Trigger event name (required).
|
|
224
|
+
--out-dir <path> Directory for the generated trigger file. Defaults to the source file's directory.
|
|
225
|
+
--dry-run Print planned changes without writing.
|
|
226
|
+
|
|
227
|
+
migrate-from-listener-middleware Generate triggers from RTK listenerMiddleware.startListening({ actionCreator, effect }).
|
|
228
|
+
--out-dir <path> Directory for the generated trigger files.
|
|
229
|
+
--dry-run Print planned changes without writing.
|
|
230
|
+
|
|
231
|
+
Examples:
|
|
232
|
+
triggery-codemod extract-trigger --name new-message src/Chat.tsx
|
|
233
|
+
triggery-codemod migrate-from-listener-middleware src/store/middleware.ts
|
|
234
|
+
`;
|
|
235
|
+
function parseArgs(argv) {
|
|
236
|
+
const [command = "", ...rest] = argv;
|
|
237
|
+
const flags = /* @__PURE__ */ new Map();
|
|
238
|
+
const positional = [];
|
|
239
|
+
for (let i = 0; i < rest.length; i++) {
|
|
240
|
+
const token = rest[i];
|
|
241
|
+
if (token.startsWith("--")) {
|
|
242
|
+
const key = token.slice(2);
|
|
243
|
+
const next = rest[i + 1];
|
|
244
|
+
if (next && !next.startsWith("--")) {
|
|
245
|
+
flags.set(key, next);
|
|
246
|
+
i += 1;
|
|
247
|
+
} else {
|
|
248
|
+
flags.set(key, true);
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
positional.push(token);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { command, flags, positional };
|
|
255
|
+
}
|
|
256
|
+
async function runCli(argv) {
|
|
257
|
+
if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
|
|
258
|
+
process.stdout.write(USAGE);
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
const { command, flags, positional } = parseArgs(argv);
|
|
262
|
+
const file = positional[0];
|
|
263
|
+
try {
|
|
264
|
+
if (command === "extract-trigger") {
|
|
265
|
+
if (!file) throw new Error("Missing source file argument.");
|
|
266
|
+
const nameFlag = flags.get("name");
|
|
267
|
+
const name = typeof nameFlag === "string" ? nameFlag : void 0;
|
|
268
|
+
if (!name) throw new Error("Missing required --name <kebab-case>.");
|
|
269
|
+
const outDirFlag = flags.get("out-dir");
|
|
270
|
+
const outDir = typeof outDirFlag === "string" ? outDirFlag : void 0;
|
|
271
|
+
const result = extractTrigger({
|
|
272
|
+
file,
|
|
273
|
+
name,
|
|
274
|
+
...outDir !== void 0 ? { outDir } : {},
|
|
275
|
+
dryRun: Boolean(flags.get("dry-run"))
|
|
276
|
+
});
|
|
277
|
+
process.stdout.write(`Generated ${result.triggerFilePath}
|
|
278
|
+
`);
|
|
279
|
+
process.stdout.write("Add this import to the component file:\n");
|
|
280
|
+
process.stdout.write(
|
|
281
|
+
` import { ${result.triggerFilePath.split("/").pop()?.replace(".trigger.ts", "Trigger")} } from './${result.triggerFilePath.split("/").pop()?.replace(".ts", "")}';
|
|
282
|
+
`
|
|
283
|
+
);
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
if (command === "migrate-from-listener-middleware") {
|
|
287
|
+
if (!file) throw new Error("Missing source file argument.");
|
|
288
|
+
const outDirFlag = flags.get("out-dir");
|
|
289
|
+
const outDir = typeof outDirFlag === "string" ? outDirFlag : void 0;
|
|
290
|
+
const result = migrateFromListenerMiddleware({
|
|
291
|
+
file,
|
|
292
|
+
...outDir !== void 0 ? { outDir } : {},
|
|
293
|
+
dryRun: Boolean(flags.get("dry-run"))
|
|
294
|
+
});
|
|
295
|
+
process.stdout.write(`Migrated ${result.migrated.length} listener(s) from ${result.file}:
|
|
296
|
+
`);
|
|
297
|
+
for (const m of result.migrated) {
|
|
298
|
+
process.stdout.write(` \u2022 ${m.eventName} \u2192 ${m.triggerFilePath}
|
|
299
|
+
`);
|
|
300
|
+
}
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
process.stderr.write(`Unknown command: ${command}
|
|
304
|
+
${USAGE}`);
|
|
305
|
+
return 1;
|
|
306
|
+
} catch (err) {
|
|
307
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
308
|
+
process.stderr.write(`Error: ${message}
|
|
309
|
+
`);
|
|
310
|
+
return 1;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
314
|
+
runCli(process.argv.slice(2)).then((code) => {
|
|
315
|
+
process.exit(code);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export { runCli };
|
|
320
|
+
//# sourceMappingURL=cli.js.map
|
|
321
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/utils.ts","../src/codemods/extract-trigger.ts","../src/codemods/migrate-from-listener-middleware.ts","../src/cli.ts"],"names":["Node","Project","renderTriggerFile","join","dirname","SyntaxKind"],"mappings":"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","file":"cli.js","sourcesContent":["import { Node } from 'ts-morph';\n\nexport function slugify(input: string): string {\n return (\n input\n // PascalCase / camelCase → kebab-case\n .replace(/([a-z0-9])([A-Z])/g, '$1-$2')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n );\n}\n\nexport function kebabToCamel(input: string): string {\n return input\n .split('-')\n .map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))\n .join('');\n}\n\nexport function kebabToPascal(input: string): string {\n return input\n .split('-')\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join('');\n}\n\n/**\n * `useEffect` calls only mean anything inside a hook or component. The\n * `extract-trigger` codemod uses this guard before rewriting the call site\n * to skip stray top-level `useEffect`s (which won't run anyway).\n */\nexport function isInsideHook(node: Node): boolean {\n let parent: Node | undefined = node.getParent();\n while (parent) {\n if (\n Node.isFunctionDeclaration(parent) ||\n Node.isArrowFunction(parent) ||\n Node.isFunctionExpression(parent) ||\n Node.isMethodDeclaration(parent)\n ) {\n const name = getFunctionName(parent);\n if (name && (/^[A-Z]/.test(name) || /^use[A-Z]/.test(name))) return true;\n }\n parent = parent.getParent();\n }\n return false;\n}\n\nfunction getFunctionName(node: Node): string | null {\n if (Node.isFunctionDeclaration(node)) {\n return node.getName() ?? null;\n }\n const parent = node.getParent();\n if (!parent) return null;\n if (Node.isVariableDeclaration(parent)) return parent.getName();\n if (Node.isPropertyAssignment(parent)) {\n const name = parent.getName();\n return name;\n }\n return null;\n}\n","import { dirname, join } from 'node:path';\nimport { type CallExpression, Node, Project, type SourceFile, SyntaxKind } from 'ts-morph';\nimport { isInsideHook, kebabToCamel, slugify } from '../utils.ts';\n\nexport interface ExtractTriggerOptions {\n /** Path to the source `.tsx` file. */\n readonly file: string;\n /** Directory where the generated `*.trigger.ts` file should land. Defaults to the source file's directory. */\n readonly outDir?: string;\n /** Trigger id (kebab-case). Used to derive symbol/file names. */\n readonly name: string;\n /**\n * If true, the codemod just plans the changes and returns them without\n * writing to disk. Useful for previews / dry-run.\n */\n readonly dryRun?: boolean;\n /**\n * Optional pre-existing ts-morph project. The codemod will add `file` to\n * it. Lets the CLI reuse one tsconfig across batches.\n */\n readonly project?: Project;\n}\n\nexport interface ExtractTriggerResult {\n readonly sourceUpdated: boolean;\n readonly triggerFilePath: string;\n readonly triggerFileContent: string;\n readonly originalEffectBody: string;\n}\n\n/**\n * Extracts the first `useEffect(() => { ... }, [])` in a component into a\n * dedicated `*.trigger.ts` file. The component is rewritten to fire the new\n * event instead of running the effect inline.\n *\n * This is a pragmatic V1 — it covers the common shape (single useEffect with\n * a clear side-effect body). For complex effects (cleanup functions,\n * multiple effects in one file, dynamic deps) you will need to follow up\n * manually; the codemod stops at the first match and reports what it did.\n */\nexport function extractTrigger(options: ExtractTriggerOptions): ExtractTriggerResult {\n const project =\n options.project ??\n new Project({\n useInMemoryFileSystem: false,\n skipFileDependencyResolution: true,\n skipLoadingLibFiles: true,\n });\n\n const source: SourceFile = project.addSourceFileAtPath(options.file);\n const useEffectCall = findFirstUseEffect(source);\n if (!useEffectCall) {\n throw new Error(\n `[triggery/codemod] No useEffect call found in ${options.file}. Nothing to extract.`,\n );\n }\n\n const handler = useEffectCall.getArguments()[0];\n if (!handler || (!Node.isArrowFunction(handler) && !Node.isFunctionExpression(handler))) {\n throw new Error(\n `[triggery/codemod] useEffect's first argument is not a function in ${options.file}.`,\n );\n }\n const body = handler.getBody();\n const bodyText = Node.isBlock(body)\n ? body\n .getText()\n .replace(/^{\\s*|\\s*}$/g, '')\n .trim()\n : `return ${body.getText()};`;\n\n const eventName = slugify(options.name);\n const symbolName = `${kebabToCamel(eventName)}Trigger`;\n const triggerFilePath = join(options.outDir ?? dirname(options.file), `${eventName}.trigger.ts`);\n\n const triggerContent = renderTriggerFile({ symbolName, eventName, bodyText });\n\n // Rewrite the source: replace the useEffect call with a useEvent(...) call.\n // We deliberately leave the import statement editing to the developer — the\n // codemod prints a hint with the import line to add. This keeps the AST\n // changes minimal and predictable.\n const useEffectStatement = useEffectCall.getFirstAncestorByKind(SyntaxKind.ExpressionStatement);\n if (useEffectStatement && isInsideHook(useEffectStatement)) {\n useEffectStatement.replaceWithText(\n `// Migrated to ./${eventName}.trigger.ts — fire the event instead of running the effect inline.\\n` +\n `useEvent(${symbolName}, '${eventName}');`,\n );\n }\n\n if (!options.dryRun) {\n project.createSourceFile(triggerFilePath, triggerContent, { overwrite: true });\n project.saveSync();\n }\n\n return {\n sourceUpdated: Boolean(useEffectStatement),\n triggerFilePath,\n triggerFileContent: triggerContent,\n originalEffectBody: bodyText,\n };\n}\n\nfunction findFirstUseEffect(source: SourceFile): CallExpression | null {\n for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {\n const expr = call.getExpression();\n if (expr.getKind() === SyntaxKind.Identifier && expr.getText() === 'useEffect') {\n return call;\n }\n }\n return null;\n}\n\nfunction renderTriggerFile(opts: {\n symbolName: string;\n eventName: string;\n bodyText: string;\n}): string {\n // Indent the original body two levels (handler body) for readability.\n const indented = opts.bodyText\n .split('\\n')\n .map((line) => (line.length === 0 ? line : ` ${line}`))\n .join('\\n');\n\n return `import { createTrigger } from '@triggery/core';\n\n/**\n * Extracted automatically by @triggery/codemod from a useEffect block.\n * Review the generated handler — the codemod does its best but cannot infer\n * the runtime \"events / conditions / actions\" surface without your input.\n *\n * Next steps:\n * 1. Declare the proper Schema generic below.\n * 2. Move side-effects into named \\`actions.<name>\\` calls; declare the\n * actions in the generic and register them via \\`useAction\\` in the\n * component(s) that own them.\n * 3. Move read-only inputs into typed \\`conditions\\` and register them via\n * \\`useCondition\\` instead of relying on captured closure state.\n * 4. Delete the TODO marker once the migration is complete.\n */\nexport const ${opts.symbolName} = createTrigger<{\n events: { '${opts.eventName}': void };\n conditions: Record<string, never>;\n actions: Record<string, never>;\n}>({\n id: '${opts.eventName}',\n events: ['${opts.eventName}'],\n required: [],\n handler({ event, conditions, actions, check }) {\n // TODO: migrated from useEffect — refactor side effects into actions.\n${indented}\n },\n});\n`;\n}\n","import { dirname, join } from 'node:path';\nimport {\n type CallExpression,\n Node,\n type ObjectLiteralExpression,\n Project,\n type SourceFile,\n SyntaxKind,\n} from 'ts-morph';\nimport { kebabToCamel, slugify } from '../utils.ts';\n\nexport interface MigrateFromListenerMiddlewareOptions {\n readonly file: string;\n readonly outDir?: string;\n readonly dryRun?: boolean;\n readonly project?: Project;\n}\n\nexport interface MigratedListener {\n readonly eventName: string;\n readonly triggerFilePath: string;\n readonly triggerFileContent: string;\n}\n\nexport interface MigrateFromListenerMiddlewareResult {\n readonly file: string;\n readonly migrated: readonly MigratedListener[];\n}\n\n/**\n * Walks a file that uses RTK's createListenerMiddleware / startListening and\n * generates one `*.trigger.ts` per `startListening({ actionCreator, effect })`\n * registration. The source file is left untouched — adopters review the\n * generated triggers, wire them into their components via `useEvent`, then\n * delete the middleware registration when ready.\n *\n * V1 supports the canonical shape:\n *\n * startListening({ actionCreator: someAction, effect: (action, api) => {...} })\n *\n * Other listenerMiddleware patterns (matcher, predicate, type) are reported\n * but not transformed in this iteration.\n */\nexport function migrateFromListenerMiddleware(\n options: MigrateFromListenerMiddlewareOptions,\n): MigrateFromListenerMiddlewareResult {\n const project =\n options.project ??\n new Project({\n useInMemoryFileSystem: false,\n skipFileDependencyResolution: true,\n skipLoadingLibFiles: true,\n });\n\n const source: SourceFile = project.addSourceFileAtPath(options.file);\n const migrated: MigratedListener[] = [];\n\n for (const call of findStartListeningCalls(source)) {\n const arg = call.getArguments()[0];\n if (!arg || !Node.isObjectLiteralExpression(arg)) continue;\n const actionCreator = readActionCreator(arg);\n const effect = readEffectBody(arg);\n if (!actionCreator || effect === null) continue;\n\n const eventName = slugify(actionCreator);\n const symbolName = `${kebabToCamel(eventName)}Trigger`;\n const triggerContent = renderTriggerFile({ symbolName, eventName, effect });\n const triggerFilePath = join(\n options.outDir ?? dirname(options.file),\n `${eventName}.trigger.ts`,\n );\n\n if (!options.dryRun) {\n project.createSourceFile(triggerFilePath, triggerContent, { overwrite: true });\n }\n migrated.push({ eventName, triggerFilePath, triggerFileContent: triggerContent });\n }\n\n if (!options.dryRun && migrated.length > 0) project.saveSync();\n return { file: options.file, migrated };\n}\n\nfunction findStartListeningCalls(source: SourceFile): CallExpression[] {\n const out: CallExpression[] = [];\n for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {\n const expr = call.getExpression();\n if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {\n const text = expr.getText();\n if (text.endsWith('.startListening')) out.push(call);\n } else if (expr.getKind() === SyntaxKind.Identifier && expr.getText() === 'startListening') {\n out.push(call);\n }\n }\n return out;\n}\n\nfunction readActionCreator(arg: ObjectLiteralExpression): string | null {\n const prop = arg.getProperty('actionCreator');\n if (!prop || !Node.isPropertyAssignment(prop)) return null;\n const init = prop.getInitializer();\n if (!init) return null;\n // Use the symbol name as the canonical event id; the codemod doesn't try to\n // resolve `.type` on the action creator (that would require type info).\n return init.getText();\n}\n\nfunction readEffectBody(arg: ObjectLiteralExpression): string | null {\n const prop = arg.getProperty('effect');\n if (!prop || !Node.isPropertyAssignment(prop)) return null;\n const init = prop.getInitializer();\n if (!init) return null;\n if (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) {\n const body = init.getBody();\n if (Node.isBlock(body))\n return body\n .getText()\n .replace(/^{\\s*|\\s*}$/g, '')\n .trim();\n return `return ${body.getText()};`;\n }\n return null;\n}\n\nfunction renderTriggerFile(opts: {\n symbolName: string;\n eventName: string;\n effect: string;\n}): string {\n const indented = opts.effect\n .split('\\n')\n .map((line) => (line.length === 0 ? line : ` ${line}`))\n .join('\\n');\n\n return `import { createTrigger } from '@triggery/core';\n\n/**\n * Auto-migrated from a Redux Toolkit listenerMiddleware \\`startListening\\`\n * registration. Review the generated handler — the original \\`effect\\` ran\n * inside an RTK store context (listenerApi.dispatch, etc.) which Triggery\n * does not provide directly. Recommended steps:\n *\n * 1. Replace \\`listenerApi.dispatch(x)\\` with a Triggery action:\n * add an \\`actions.<name>\\` entry to the generic and call it inside the\n * handler.\n * 2. Replace reads of \\`listenerApi.getState()\\` with typed conditions.\n * 3. Wire the new \\`actions\\` via \\`useAction\\` and \\`conditions\\` via\n * \\`useCondition\\` in the appropriate components.\n */\nexport const ${opts.symbolName} = createTrigger<{\n events: { '${opts.eventName}': unknown };\n conditions: Record<string, never>;\n actions: Record<string, never>;\n}>({\n id: '${opts.eventName}',\n events: ['${opts.eventName}'],\n required: [],\n async handler({ event, conditions, actions, check }) {\n // TODO: original RTK effect body — refactor dispatch/getState into actions/conditions.\n const action = event.payload;\n${indented}\n },\n});\n`;\n}\n","import { extractTrigger } from './codemods/extract-trigger.ts';\nimport { migrateFromListenerMiddleware } from './codemods/migrate-from-listener-middleware.ts';\n\nconst USAGE = `Usage: triggery-codemod <command> [options] <file>\n\nCommands:\n extract-trigger Extract a useEffect block into a *.trigger.ts file.\n --name <kebab-case> Trigger event name (required).\n --out-dir <path> Directory for the generated trigger file. Defaults to the source file's directory.\n --dry-run Print planned changes without writing.\n\n migrate-from-listener-middleware Generate triggers from RTK listenerMiddleware.startListening({ actionCreator, effect }).\n --out-dir <path> Directory for the generated trigger files.\n --dry-run Print planned changes without writing.\n\nExamples:\n triggery-codemod extract-trigger --name new-message src/Chat.tsx\n triggery-codemod migrate-from-listener-middleware src/store/middleware.ts\n`;\n\nfunction parseArgs(argv: readonly string[]): {\n command: string;\n flags: Map<string, string | boolean>;\n positional: readonly string[];\n} {\n const [command = '', ...rest] = argv;\n const flags = new Map<string, string | boolean>();\n const positional: string[] = [];\n for (let i = 0; i < rest.length; i++) {\n const token = rest[i] as string;\n if (token.startsWith('--')) {\n const key = token.slice(2);\n const next = rest[i + 1];\n if (next && !next.startsWith('--')) {\n flags.set(key, next);\n i += 1;\n } else {\n flags.set(key, true);\n }\n } else {\n positional.push(token);\n }\n }\n return { command, flags, positional };\n}\n\nexport async function runCli(argv: readonly string[]): Promise<number> {\n if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {\n process.stdout.write(USAGE);\n return 0;\n }\n const { command, flags, positional } = parseArgs(argv);\n const file = positional[0];\n\n try {\n if (command === 'extract-trigger') {\n if (!file) throw new Error('Missing source file argument.');\n const nameFlag = flags.get('name');\n const name = typeof nameFlag === 'string' ? nameFlag : undefined;\n if (!name) throw new Error('Missing required --name <kebab-case>.');\n const outDirFlag = flags.get('out-dir');\n const outDir = typeof outDirFlag === 'string' ? outDirFlag : undefined;\n const result = extractTrigger({\n file,\n name,\n ...(outDir !== undefined ? { outDir } : {}),\n dryRun: Boolean(flags.get('dry-run')),\n });\n process.stdout.write(`Generated ${result.triggerFilePath}\\n`);\n process.stdout.write('Add this import to the component file:\\n');\n process.stdout.write(\n ` import { ${result.triggerFilePath.split('/').pop()?.replace('.trigger.ts', 'Trigger')} } from './${result.triggerFilePath.split('/').pop()?.replace('.ts', '')}';\\n`,\n );\n return 0;\n }\n\n if (command === 'migrate-from-listener-middleware') {\n if (!file) throw new Error('Missing source file argument.');\n const outDirFlag = flags.get('out-dir');\n const outDir = typeof outDirFlag === 'string' ? outDirFlag : undefined;\n const result = migrateFromListenerMiddleware({\n file,\n ...(outDir !== undefined ? { outDir } : {}),\n dryRun: Boolean(flags.get('dry-run')),\n });\n process.stdout.write(`Migrated ${result.migrated.length} listener(s) from ${result.file}:\\n`);\n for (const m of result.migrated) {\n process.stdout.write(` • ${m.eventName} → ${m.triggerFilePath}\\n`);\n }\n return 0;\n }\n\n process.stderr.write(`Unknown command: ${command}\\n${USAGE}`);\n return 1;\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n process.stderr.write(`Error: ${message}\\n`);\n return 1;\n }\n}\n\n// Standalone execution: `triggery-codemod extract-trigger --name x src/foo.tsx`.\n// When tsup bundles this file as a `bin`, the banner `#!/usr/bin/env node` is\n// prepended automatically and import.meta.url is the absolute file URL.\nif (import.meta.url === `file://${process.argv[1]}`) {\n runCli(process.argv.slice(2)).then((code) => {\n process.exit(code);\n });\n}\n"]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Project } from 'ts-morph';
|
|
2
|
+
|
|
3
|
+
interface ExtractTriggerOptions {
|
|
4
|
+
/** Path to the source `.tsx` file. */
|
|
5
|
+
readonly file: string;
|
|
6
|
+
/** Directory where the generated `*.trigger.ts` file should land. Defaults to the source file's directory. */
|
|
7
|
+
readonly outDir?: string;
|
|
8
|
+
/** Trigger id (kebab-case). Used to derive symbol/file names. */
|
|
9
|
+
readonly name: string;
|
|
10
|
+
/**
|
|
11
|
+
* If true, the codemod just plans the changes and returns them without
|
|
12
|
+
* writing to disk. Useful for previews / dry-run.
|
|
13
|
+
*/
|
|
14
|
+
readonly dryRun?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Optional pre-existing ts-morph project. The codemod will add `file` to
|
|
17
|
+
* it. Lets the CLI reuse one tsconfig across batches.
|
|
18
|
+
*/
|
|
19
|
+
readonly project?: Project;
|
|
20
|
+
}
|
|
21
|
+
interface ExtractTriggerResult {
|
|
22
|
+
readonly sourceUpdated: boolean;
|
|
23
|
+
readonly triggerFilePath: string;
|
|
24
|
+
readonly triggerFileContent: string;
|
|
25
|
+
readonly originalEffectBody: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Extracts the first `useEffect(() => { ... }, [])` in a component into a
|
|
29
|
+
* dedicated `*.trigger.ts` file. The component is rewritten to fire the new
|
|
30
|
+
* event instead of running the effect inline.
|
|
31
|
+
*
|
|
32
|
+
* This is a pragmatic V1 — it covers the common shape (single useEffect with
|
|
33
|
+
* a clear side-effect body). For complex effects (cleanup functions,
|
|
34
|
+
* multiple effects in one file, dynamic deps) you will need to follow up
|
|
35
|
+
* manually; the codemod stops at the first match and reports what it did.
|
|
36
|
+
*/
|
|
37
|
+
declare function extractTrigger(options: ExtractTriggerOptions): ExtractTriggerResult;
|
|
38
|
+
|
|
39
|
+
interface MigrateFromListenerMiddlewareOptions {
|
|
40
|
+
readonly file: string;
|
|
41
|
+
readonly outDir?: string;
|
|
42
|
+
readonly dryRun?: boolean;
|
|
43
|
+
readonly project?: Project;
|
|
44
|
+
}
|
|
45
|
+
interface MigratedListener {
|
|
46
|
+
readonly eventName: string;
|
|
47
|
+
readonly triggerFilePath: string;
|
|
48
|
+
readonly triggerFileContent: string;
|
|
49
|
+
}
|
|
50
|
+
interface MigrateFromListenerMiddlewareResult {
|
|
51
|
+
readonly file: string;
|
|
52
|
+
readonly migrated: readonly MigratedListener[];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Walks a file that uses RTK's createListenerMiddleware / startListening and
|
|
56
|
+
* generates one `*.trigger.ts` per `startListening({ actionCreator, effect })`
|
|
57
|
+
* registration. The source file is left untouched — adopters review the
|
|
58
|
+
* generated triggers, wire them into their components via `useEvent`, then
|
|
59
|
+
* delete the middleware registration when ready.
|
|
60
|
+
*
|
|
61
|
+
* V1 supports the canonical shape:
|
|
62
|
+
*
|
|
63
|
+
* startListening({ actionCreator: someAction, effect: (action, api) => {...} })
|
|
64
|
+
*
|
|
65
|
+
* Other listenerMiddleware patterns (matcher, predicate, type) are reported
|
|
66
|
+
* but not transformed in this iteration.
|
|
67
|
+
*/
|
|
68
|
+
declare function migrateFromListenerMiddleware(options: MigrateFromListenerMiddlewareOptions): MigrateFromListenerMiddlewareResult;
|
|
69
|
+
|
|
70
|
+
export { type ExtractTriggerOptions, type ExtractTriggerResult, type MigrateFromListenerMiddlewareOptions, type MigrateFromListenerMiddlewareResult, type MigratedListener, extractTrigger, migrateFromListenerMiddleware };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { Project, Node, SyntaxKind } from 'ts-morph';
|
|
4
|
+
|
|
5
|
+
function slugify(input) {
|
|
6
|
+
return input.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
7
|
+
}
|
|
8
|
+
function kebabToCamel(input) {
|
|
9
|
+
return input.split("-").map((part, i) => i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
10
|
+
}
|
|
11
|
+
function isInsideHook(node) {
|
|
12
|
+
let parent = node.getParent();
|
|
13
|
+
while (parent) {
|
|
14
|
+
if (Node.isFunctionDeclaration(parent) || Node.isArrowFunction(parent) || Node.isFunctionExpression(parent) || Node.isMethodDeclaration(parent)) {
|
|
15
|
+
const name = getFunctionName(parent);
|
|
16
|
+
if (name && (/^[A-Z]/.test(name) || /^use[A-Z]/.test(name))) return true;
|
|
17
|
+
}
|
|
18
|
+
parent = parent.getParent();
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
function getFunctionName(node) {
|
|
23
|
+
if (Node.isFunctionDeclaration(node)) {
|
|
24
|
+
return node.getName() ?? null;
|
|
25
|
+
}
|
|
26
|
+
const parent = node.getParent();
|
|
27
|
+
if (!parent) return null;
|
|
28
|
+
if (Node.isVariableDeclaration(parent)) return parent.getName();
|
|
29
|
+
if (Node.isPropertyAssignment(parent)) {
|
|
30
|
+
const name = parent.getName();
|
|
31
|
+
return name;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/codemods/extract-trigger.ts
|
|
37
|
+
function extractTrigger(options) {
|
|
38
|
+
const project = options.project ?? new Project({
|
|
39
|
+
useInMemoryFileSystem: false,
|
|
40
|
+
skipFileDependencyResolution: true,
|
|
41
|
+
skipLoadingLibFiles: true
|
|
42
|
+
});
|
|
43
|
+
const source = project.addSourceFileAtPath(options.file);
|
|
44
|
+
const useEffectCall = findFirstUseEffect(source);
|
|
45
|
+
if (!useEffectCall) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`[triggery/codemod] No useEffect call found in ${options.file}. Nothing to extract.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const handler = useEffectCall.getArguments()[0];
|
|
51
|
+
if (!handler || !Node.isArrowFunction(handler) && !Node.isFunctionExpression(handler)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`[triggery/codemod] useEffect's first argument is not a function in ${options.file}.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const body = handler.getBody();
|
|
57
|
+
const bodyText = Node.isBlock(body) ? body.getText().replace(/^{\s*|\s*}$/g, "").trim() : `return ${body.getText()};`;
|
|
58
|
+
const eventName = slugify(options.name);
|
|
59
|
+
const symbolName = `${kebabToCamel(eventName)}Trigger`;
|
|
60
|
+
const triggerFilePath = join(options.outDir ?? dirname(options.file), `${eventName}.trigger.ts`);
|
|
61
|
+
const triggerContent = renderTriggerFile({ symbolName, eventName, bodyText });
|
|
62
|
+
const useEffectStatement = useEffectCall.getFirstAncestorByKind(SyntaxKind.ExpressionStatement);
|
|
63
|
+
if (useEffectStatement && isInsideHook(useEffectStatement)) {
|
|
64
|
+
useEffectStatement.replaceWithText(
|
|
65
|
+
`// Migrated to ./${eventName}.trigger.ts \u2014 fire the event instead of running the effect inline.
|
|
66
|
+
useEvent(${symbolName}, '${eventName}');`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (!options.dryRun) {
|
|
70
|
+
project.createSourceFile(triggerFilePath, triggerContent, { overwrite: true });
|
|
71
|
+
project.saveSync();
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
sourceUpdated: Boolean(useEffectStatement),
|
|
75
|
+
triggerFilePath,
|
|
76
|
+
triggerFileContent: triggerContent,
|
|
77
|
+
originalEffectBody: bodyText
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function findFirstUseEffect(source) {
|
|
81
|
+
for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
82
|
+
const expr = call.getExpression();
|
|
83
|
+
if (expr.getKind() === SyntaxKind.Identifier && expr.getText() === "useEffect") {
|
|
84
|
+
return call;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
function renderTriggerFile(opts) {
|
|
90
|
+
const indented = opts.bodyText.split("\n").map((line) => line.length === 0 ? line : ` ${line}`).join("\n");
|
|
91
|
+
return `import { createTrigger } from '@triggery/core';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extracted automatically by @triggery/codemod from a useEffect block.
|
|
95
|
+
* Review the generated handler \u2014 the codemod does its best but cannot infer
|
|
96
|
+
* the runtime "events / conditions / actions" surface without your input.
|
|
97
|
+
*
|
|
98
|
+
* Next steps:
|
|
99
|
+
* 1. Declare the proper Schema generic below.
|
|
100
|
+
* 2. Move side-effects into named \`actions.<name>\` calls; declare the
|
|
101
|
+
* actions in the generic and register them via \`useAction\` in the
|
|
102
|
+
* component(s) that own them.
|
|
103
|
+
* 3. Move read-only inputs into typed \`conditions\` and register them via
|
|
104
|
+
* \`useCondition\` instead of relying on captured closure state.
|
|
105
|
+
* 4. Delete the TODO marker once the migration is complete.
|
|
106
|
+
*/
|
|
107
|
+
export const ${opts.symbolName} = createTrigger<{
|
|
108
|
+
events: { '${opts.eventName}': void };
|
|
109
|
+
conditions: Record<string, never>;
|
|
110
|
+
actions: Record<string, never>;
|
|
111
|
+
}>({
|
|
112
|
+
id: '${opts.eventName}',
|
|
113
|
+
events: ['${opts.eventName}'],
|
|
114
|
+
required: [],
|
|
115
|
+
handler({ event, conditions, actions, check }) {
|
|
116
|
+
// TODO: migrated from useEffect \u2014 refactor side effects into actions.
|
|
117
|
+
${indented}
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
`;
|
|
121
|
+
}
|
|
122
|
+
function migrateFromListenerMiddleware(options) {
|
|
123
|
+
const project = options.project ?? new Project({
|
|
124
|
+
useInMemoryFileSystem: false,
|
|
125
|
+
skipFileDependencyResolution: true,
|
|
126
|
+
skipLoadingLibFiles: true
|
|
127
|
+
});
|
|
128
|
+
const source = project.addSourceFileAtPath(options.file);
|
|
129
|
+
const migrated = [];
|
|
130
|
+
for (const call of findStartListeningCalls(source)) {
|
|
131
|
+
const arg = call.getArguments()[0];
|
|
132
|
+
if (!arg || !Node.isObjectLiteralExpression(arg)) continue;
|
|
133
|
+
const actionCreator = readActionCreator(arg);
|
|
134
|
+
const effect = readEffectBody(arg);
|
|
135
|
+
if (!actionCreator || effect === null) continue;
|
|
136
|
+
const eventName = slugify(actionCreator);
|
|
137
|
+
const symbolName = `${kebabToCamel(eventName)}Trigger`;
|
|
138
|
+
const triggerContent = renderTriggerFile2({ symbolName, eventName, effect });
|
|
139
|
+
const triggerFilePath = join(
|
|
140
|
+
options.outDir ?? dirname(options.file),
|
|
141
|
+
`${eventName}.trigger.ts`
|
|
142
|
+
);
|
|
143
|
+
if (!options.dryRun) {
|
|
144
|
+
project.createSourceFile(triggerFilePath, triggerContent, { overwrite: true });
|
|
145
|
+
}
|
|
146
|
+
migrated.push({ eventName, triggerFilePath, triggerFileContent: triggerContent });
|
|
147
|
+
}
|
|
148
|
+
if (!options.dryRun && migrated.length > 0) project.saveSync();
|
|
149
|
+
return { file: options.file, migrated };
|
|
150
|
+
}
|
|
151
|
+
function findStartListeningCalls(source) {
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
154
|
+
const expr = call.getExpression();
|
|
155
|
+
if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
156
|
+
const text = expr.getText();
|
|
157
|
+
if (text.endsWith(".startListening")) out.push(call);
|
|
158
|
+
} else if (expr.getKind() === SyntaxKind.Identifier && expr.getText() === "startListening") {
|
|
159
|
+
out.push(call);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
function readActionCreator(arg) {
|
|
165
|
+
const prop = arg.getProperty("actionCreator");
|
|
166
|
+
if (!prop || !Node.isPropertyAssignment(prop)) return null;
|
|
167
|
+
const init = prop.getInitializer();
|
|
168
|
+
if (!init) return null;
|
|
169
|
+
return init.getText();
|
|
170
|
+
}
|
|
171
|
+
function readEffectBody(arg) {
|
|
172
|
+
const prop = arg.getProperty("effect");
|
|
173
|
+
if (!prop || !Node.isPropertyAssignment(prop)) return null;
|
|
174
|
+
const init = prop.getInitializer();
|
|
175
|
+
if (!init) return null;
|
|
176
|
+
if (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) {
|
|
177
|
+
const body = init.getBody();
|
|
178
|
+
if (Node.isBlock(body))
|
|
179
|
+
return body.getText().replace(/^{\s*|\s*}$/g, "").trim();
|
|
180
|
+
return `return ${body.getText()};`;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
function renderTriggerFile2(opts) {
|
|
185
|
+
const indented = opts.effect.split("\n").map((line) => line.length === 0 ? line : ` ${line}`).join("\n");
|
|
186
|
+
return `import { createTrigger } from '@triggery/core';
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Auto-migrated from a Redux Toolkit listenerMiddleware \`startListening\`
|
|
190
|
+
* registration. Review the generated handler \u2014 the original \`effect\` ran
|
|
191
|
+
* inside an RTK store context (listenerApi.dispatch, etc.) which Triggery
|
|
192
|
+
* does not provide directly. Recommended steps:
|
|
193
|
+
*
|
|
194
|
+
* 1. Replace \`listenerApi.dispatch(x)\` with a Triggery action:
|
|
195
|
+
* add an \`actions.<name>\` entry to the generic and call it inside the
|
|
196
|
+
* handler.
|
|
197
|
+
* 2. Replace reads of \`listenerApi.getState()\` with typed conditions.
|
|
198
|
+
* 3. Wire the new \`actions\` via \`useAction\` and \`conditions\` via
|
|
199
|
+
* \`useCondition\` in the appropriate components.
|
|
200
|
+
*/
|
|
201
|
+
export const ${opts.symbolName} = createTrigger<{
|
|
202
|
+
events: { '${opts.eventName}': unknown };
|
|
203
|
+
conditions: Record<string, never>;
|
|
204
|
+
actions: Record<string, never>;
|
|
205
|
+
}>({
|
|
206
|
+
id: '${opts.eventName}',
|
|
207
|
+
events: ['${opts.eventName}'],
|
|
208
|
+
required: [],
|
|
209
|
+
async handler({ event, conditions, actions, check }) {
|
|
210
|
+
// TODO: original RTK effect body \u2014 refactor dispatch/getState into actions/conditions.
|
|
211
|
+
const action = event.payload;
|
|
212
|
+
${indented}
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export { extractTrigger, migrateFromListenerMiddleware };
|
|
219
|
+
//# sourceMappingURL=index.js.map
|
|
220
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/utils.ts","../src/codemods/extract-trigger.ts","../src/codemods/migrate-from-listener-middleware.ts"],"names":["Node","Project","renderTriggerFile","join","dirname","SyntaxKind"],"mappings":"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","file":"index.js","sourcesContent":["import { Node } from 'ts-morph';\n\nexport function slugify(input: string): string {\n return (\n input\n // PascalCase / camelCase → kebab-case\n .replace(/([a-z0-9])([A-Z])/g, '$1-$2')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n );\n}\n\nexport function kebabToCamel(input: string): string {\n return input\n .split('-')\n .map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))\n .join('');\n}\n\nexport function kebabToPascal(input: string): string {\n return input\n .split('-')\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join('');\n}\n\n/**\n * `useEffect` calls only mean anything inside a hook or component. The\n * `extract-trigger` codemod uses this guard before rewriting the call site\n * to skip stray top-level `useEffect`s (which won't run anyway).\n */\nexport function isInsideHook(node: Node): boolean {\n let parent: Node | undefined = node.getParent();\n while (parent) {\n if (\n Node.isFunctionDeclaration(parent) ||\n Node.isArrowFunction(parent) ||\n Node.isFunctionExpression(parent) ||\n Node.isMethodDeclaration(parent)\n ) {\n const name = getFunctionName(parent);\n if (name && (/^[A-Z]/.test(name) || /^use[A-Z]/.test(name))) return true;\n }\n parent = parent.getParent();\n }\n return false;\n}\n\nfunction getFunctionName(node: Node): string | null {\n if (Node.isFunctionDeclaration(node)) {\n return node.getName() ?? null;\n }\n const parent = node.getParent();\n if (!parent) return null;\n if (Node.isVariableDeclaration(parent)) return parent.getName();\n if (Node.isPropertyAssignment(parent)) {\n const name = parent.getName();\n return name;\n }\n return null;\n}\n","import { dirname, join } from 'node:path';\nimport { type CallExpression, Node, Project, type SourceFile, SyntaxKind } from 'ts-morph';\nimport { isInsideHook, kebabToCamel, slugify } from '../utils.ts';\n\nexport interface ExtractTriggerOptions {\n /** Path to the source `.tsx` file. */\n readonly file: string;\n /** Directory where the generated `*.trigger.ts` file should land. Defaults to the source file's directory. */\n readonly outDir?: string;\n /** Trigger id (kebab-case). Used to derive symbol/file names. */\n readonly name: string;\n /**\n * If true, the codemod just plans the changes and returns them without\n * writing to disk. Useful for previews / dry-run.\n */\n readonly dryRun?: boolean;\n /**\n * Optional pre-existing ts-morph project. The codemod will add `file` to\n * it. Lets the CLI reuse one tsconfig across batches.\n */\n readonly project?: Project;\n}\n\nexport interface ExtractTriggerResult {\n readonly sourceUpdated: boolean;\n readonly triggerFilePath: string;\n readonly triggerFileContent: string;\n readonly originalEffectBody: string;\n}\n\n/**\n * Extracts the first `useEffect(() => { ... }, [])` in a component into a\n * dedicated `*.trigger.ts` file. The component is rewritten to fire the new\n * event instead of running the effect inline.\n *\n * This is a pragmatic V1 — it covers the common shape (single useEffect with\n * a clear side-effect body). For complex effects (cleanup functions,\n * multiple effects in one file, dynamic deps) you will need to follow up\n * manually; the codemod stops at the first match and reports what it did.\n */\nexport function extractTrigger(options: ExtractTriggerOptions): ExtractTriggerResult {\n const project =\n options.project ??\n new Project({\n useInMemoryFileSystem: false,\n skipFileDependencyResolution: true,\n skipLoadingLibFiles: true,\n });\n\n const source: SourceFile = project.addSourceFileAtPath(options.file);\n const useEffectCall = findFirstUseEffect(source);\n if (!useEffectCall) {\n throw new Error(\n `[triggery/codemod] No useEffect call found in ${options.file}. Nothing to extract.`,\n );\n }\n\n const handler = useEffectCall.getArguments()[0];\n if (!handler || (!Node.isArrowFunction(handler) && !Node.isFunctionExpression(handler))) {\n throw new Error(\n `[triggery/codemod] useEffect's first argument is not a function in ${options.file}.`,\n );\n }\n const body = handler.getBody();\n const bodyText = Node.isBlock(body)\n ? body\n .getText()\n .replace(/^{\\s*|\\s*}$/g, '')\n .trim()\n : `return ${body.getText()};`;\n\n const eventName = slugify(options.name);\n const symbolName = `${kebabToCamel(eventName)}Trigger`;\n const triggerFilePath = join(options.outDir ?? dirname(options.file), `${eventName}.trigger.ts`);\n\n const triggerContent = renderTriggerFile({ symbolName, eventName, bodyText });\n\n // Rewrite the source: replace the useEffect call with a useEvent(...) call.\n // We deliberately leave the import statement editing to the developer — the\n // codemod prints a hint with the import line to add. This keeps the AST\n // changes minimal and predictable.\n const useEffectStatement = useEffectCall.getFirstAncestorByKind(SyntaxKind.ExpressionStatement);\n if (useEffectStatement && isInsideHook(useEffectStatement)) {\n useEffectStatement.replaceWithText(\n `// Migrated to ./${eventName}.trigger.ts — fire the event instead of running the effect inline.\\n` +\n `useEvent(${symbolName}, '${eventName}');`,\n );\n }\n\n if (!options.dryRun) {\n project.createSourceFile(triggerFilePath, triggerContent, { overwrite: true });\n project.saveSync();\n }\n\n return {\n sourceUpdated: Boolean(useEffectStatement),\n triggerFilePath,\n triggerFileContent: triggerContent,\n originalEffectBody: bodyText,\n };\n}\n\nfunction findFirstUseEffect(source: SourceFile): CallExpression | null {\n for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {\n const expr = call.getExpression();\n if (expr.getKind() === SyntaxKind.Identifier && expr.getText() === 'useEffect') {\n return call;\n }\n }\n return null;\n}\n\nfunction renderTriggerFile(opts: {\n symbolName: string;\n eventName: string;\n bodyText: string;\n}): string {\n // Indent the original body two levels (handler body) for readability.\n const indented = opts.bodyText\n .split('\\n')\n .map((line) => (line.length === 0 ? line : ` ${line}`))\n .join('\\n');\n\n return `import { createTrigger } from '@triggery/core';\n\n/**\n * Extracted automatically by @triggery/codemod from a useEffect block.\n * Review the generated handler — the codemod does its best but cannot infer\n * the runtime \"events / conditions / actions\" surface without your input.\n *\n * Next steps:\n * 1. Declare the proper Schema generic below.\n * 2. Move side-effects into named \\`actions.<name>\\` calls; declare the\n * actions in the generic and register them via \\`useAction\\` in the\n * component(s) that own them.\n * 3. Move read-only inputs into typed \\`conditions\\` and register them via\n * \\`useCondition\\` instead of relying on captured closure state.\n * 4. Delete the TODO marker once the migration is complete.\n */\nexport const ${opts.symbolName} = createTrigger<{\n events: { '${opts.eventName}': void };\n conditions: Record<string, never>;\n actions: Record<string, never>;\n}>({\n id: '${opts.eventName}',\n events: ['${opts.eventName}'],\n required: [],\n handler({ event, conditions, actions, check }) {\n // TODO: migrated from useEffect — refactor side effects into actions.\n${indented}\n },\n});\n`;\n}\n","import { dirname, join } from 'node:path';\nimport {\n type CallExpression,\n Node,\n type ObjectLiteralExpression,\n Project,\n type SourceFile,\n SyntaxKind,\n} from 'ts-morph';\nimport { kebabToCamel, slugify } from '../utils.ts';\n\nexport interface MigrateFromListenerMiddlewareOptions {\n readonly file: string;\n readonly outDir?: string;\n readonly dryRun?: boolean;\n readonly project?: Project;\n}\n\nexport interface MigratedListener {\n readonly eventName: string;\n readonly triggerFilePath: string;\n readonly triggerFileContent: string;\n}\n\nexport interface MigrateFromListenerMiddlewareResult {\n readonly file: string;\n readonly migrated: readonly MigratedListener[];\n}\n\n/**\n * Walks a file that uses RTK's createListenerMiddleware / startListening and\n * generates one `*.trigger.ts` per `startListening({ actionCreator, effect })`\n * registration. The source file is left untouched — adopters review the\n * generated triggers, wire them into their components via `useEvent`, then\n * delete the middleware registration when ready.\n *\n * V1 supports the canonical shape:\n *\n * startListening({ actionCreator: someAction, effect: (action, api) => {...} })\n *\n * Other listenerMiddleware patterns (matcher, predicate, type) are reported\n * but not transformed in this iteration.\n */\nexport function migrateFromListenerMiddleware(\n options: MigrateFromListenerMiddlewareOptions,\n): MigrateFromListenerMiddlewareResult {\n const project =\n options.project ??\n new Project({\n useInMemoryFileSystem: false,\n skipFileDependencyResolution: true,\n skipLoadingLibFiles: true,\n });\n\n const source: SourceFile = project.addSourceFileAtPath(options.file);\n const migrated: MigratedListener[] = [];\n\n for (const call of findStartListeningCalls(source)) {\n const arg = call.getArguments()[0];\n if (!arg || !Node.isObjectLiteralExpression(arg)) continue;\n const actionCreator = readActionCreator(arg);\n const effect = readEffectBody(arg);\n if (!actionCreator || effect === null) continue;\n\n const eventName = slugify(actionCreator);\n const symbolName = `${kebabToCamel(eventName)}Trigger`;\n const triggerContent = renderTriggerFile({ symbolName, eventName, effect });\n const triggerFilePath = join(\n options.outDir ?? dirname(options.file),\n `${eventName}.trigger.ts`,\n );\n\n if (!options.dryRun) {\n project.createSourceFile(triggerFilePath, triggerContent, { overwrite: true });\n }\n migrated.push({ eventName, triggerFilePath, triggerFileContent: triggerContent });\n }\n\n if (!options.dryRun && migrated.length > 0) project.saveSync();\n return { file: options.file, migrated };\n}\n\nfunction findStartListeningCalls(source: SourceFile): CallExpression[] {\n const out: CallExpression[] = [];\n for (const call of source.getDescendantsOfKind(SyntaxKind.CallExpression)) {\n const expr = call.getExpression();\n if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {\n const text = expr.getText();\n if (text.endsWith('.startListening')) out.push(call);\n } else if (expr.getKind() === SyntaxKind.Identifier && expr.getText() === 'startListening') {\n out.push(call);\n }\n }\n return out;\n}\n\nfunction readActionCreator(arg: ObjectLiteralExpression): string | null {\n const prop = arg.getProperty('actionCreator');\n if (!prop || !Node.isPropertyAssignment(prop)) return null;\n const init = prop.getInitializer();\n if (!init) return null;\n // Use the symbol name as the canonical event id; the codemod doesn't try to\n // resolve `.type` on the action creator (that would require type info).\n return init.getText();\n}\n\nfunction readEffectBody(arg: ObjectLiteralExpression): string | null {\n const prop = arg.getProperty('effect');\n if (!prop || !Node.isPropertyAssignment(prop)) return null;\n const init = prop.getInitializer();\n if (!init) return null;\n if (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) {\n const body = init.getBody();\n if (Node.isBlock(body))\n return body\n .getText()\n .replace(/^{\\s*|\\s*}$/g, '')\n .trim();\n return `return ${body.getText()};`;\n }\n return null;\n}\n\nfunction renderTriggerFile(opts: {\n symbolName: string;\n eventName: string;\n effect: string;\n}): string {\n const indented = opts.effect\n .split('\\n')\n .map((line) => (line.length === 0 ? line : ` ${line}`))\n .join('\\n');\n\n return `import { createTrigger } from '@triggery/core';\n\n/**\n * Auto-migrated from a Redux Toolkit listenerMiddleware \\`startListening\\`\n * registration. Review the generated handler — the original \\`effect\\` ran\n * inside an RTK store context (listenerApi.dispatch, etc.) which Triggery\n * does not provide directly. Recommended steps:\n *\n * 1. Replace \\`listenerApi.dispatch(x)\\` with a Triggery action:\n * add an \\`actions.<name>\\` entry to the generic and call it inside the\n * handler.\n * 2. Replace reads of \\`listenerApi.getState()\\` with typed conditions.\n * 3. Wire the new \\`actions\\` via \\`useAction\\` and \\`conditions\\` via\n * \\`useCondition\\` in the appropriate components.\n */\nexport const ${opts.symbolName} = createTrigger<{\n events: { '${opts.eventName}': unknown };\n conditions: Record<string, never>;\n actions: Record<string, never>;\n}>({\n id: '${opts.eventName}',\n events: ['${opts.eventName}'],\n required: [],\n async handler({ event, conditions, actions, check }) {\n // TODO: original RTK effect body — refactor dispatch/getState into actions/conditions.\n const action = event.payload;\n${indented}\n },\n});\n`;\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@triggery/codemod",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Codemods for migrating React/Redux side-effect code to Triggery — ts-morph powered.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Aleksey Skhomenko",
|
|
7
|
+
"homepage": "https://triggeryjs.github.io/triggery",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/triggeryjs/triggery.git",
|
|
11
|
+
"directory": "packages/codemod"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/triggeryjs/triggery/issues",
|
|
14
|
+
"funding": [
|
|
15
|
+
{
|
|
16
|
+
"type": "patreon",
|
|
17
|
+
"url": "https://www.patreon.com/triggery"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"type": "boosty",
|
|
21
|
+
"url": "https://boosty.to/triggery"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"triggery",
|
|
26
|
+
"codemod",
|
|
27
|
+
"ts-morph",
|
|
28
|
+
"migration",
|
|
29
|
+
"refactor",
|
|
30
|
+
"redux",
|
|
31
|
+
"listener-middleware"
|
|
32
|
+
],
|
|
33
|
+
"type": "module",
|
|
34
|
+
"main": "./dist/index.js",
|
|
35
|
+
"module": "./dist/index.js",
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"bin": {
|
|
38
|
+
"triggery-codemod": "./dist/cli.js"
|
|
39
|
+
},
|
|
40
|
+
"exports": {
|
|
41
|
+
".": {
|
|
42
|
+
"source": "./src/index.ts",
|
|
43
|
+
"types": "./dist/index.d.ts",
|
|
44
|
+
"import": "./dist/index.js",
|
|
45
|
+
"default": "./dist/index.js"
|
|
46
|
+
},
|
|
47
|
+
"./package.json": "./package.json"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"dist",
|
|
51
|
+
"README.md",
|
|
52
|
+
"LICENSE",
|
|
53
|
+
"CHANGELOG.md"
|
|
54
|
+
],
|
|
55
|
+
"sideEffects": false,
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"ts-morph": "^24.0.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"tsup": "^8.5.1",
|
|
64
|
+
"typescript": "^6.0.3",
|
|
65
|
+
"vitest": "^4.1.6"
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"build": "tsup",
|
|
69
|
+
"dev": "tsup --watch",
|
|
70
|
+
"test": "vitest run",
|
|
71
|
+
"test:watch": "vitest",
|
|
72
|
+
"test:coverage": "vitest run --coverage",
|
|
73
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
74
|
+
}
|
|
75
|
+
}
|