@bpmn-io/codemods 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/README.md +59 -0
- package/bin/codemods.js +46 -0
- package/esm/README.md +70 -0
- package/esm/lib/cli.js +74 -0
- package/esm/lib/migrate.js +92 -0
- package/esm/lib/resolve.js +177 -0
- package/esm/lib/transform.js +151 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @bpmn-io/codemods
|
|
2
|
+
|
|
3
|
+
A collection of codemods for the bpmn.io ecosystem, exposed via CLI.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @bpmn-io/codemods <mod> [options]
|
|
9
|
+
|
|
10
|
+
# e.g.
|
|
11
|
+
npx @bpmn-io/codemods esm --dry-run src
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Run a mod with `--help` for its options.
|
|
15
|
+
|
|
16
|
+
## Available mods
|
|
17
|
+
|
|
18
|
+
| Mod | Purpose |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| [`esm`](./esm) | Append explicit extensions to imports so they resolve under native ES module resolution (e.g. migrating to "ESM only" packages such as diagram-js). |
|
|
21
|
+
|
|
22
|
+
## Development
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install
|
|
26
|
+
npm test
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`npm test` runs the tests of every mod (`*/test/**/*.spec.js`).
|
|
30
|
+
|
|
31
|
+
## Adding a mod
|
|
32
|
+
|
|
33
|
+
> [!NOTE]
|
|
34
|
+
> Each mod lives in its own sub-folder with its own library API and tests and is run through a single CLI.
|
|
35
|
+
|
|
36
|
+
Create a new sub-folder mirroring [`esm`](./esm):
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
<mod>/
|
|
40
|
+
lib/cli.js # exports `run(argv)` — the CLI handler
|
|
41
|
+
lib/ # library implementation
|
|
42
|
+
test/ # *.spec.js + fixtures
|
|
43
|
+
README.md
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then register it in [bin/codemods.js](./bin/codemods.js):
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
import { run as myMod } from '../<mod>/lib/cli.js';
|
|
50
|
+
|
|
51
|
+
const MODS = {
|
|
52
|
+
esm,
|
|
53
|
+
myMod
|
|
54
|
+
};
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Add a row to the table above and list `<mod>/lib` in the `files` field of the
|
|
58
|
+
root [package.json](./package.json). Shared dev dependencies (`@babel/parser`,
|
|
59
|
+
`mocha`, `chai`) live at the root.
|
package/bin/codemods.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run as esm } from '../esm/lib/cli.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Registry of available codemods, keyed by their CLI name.
|
|
6
|
+
*
|
|
7
|
+
* Each handler receives the arguments that follow the mod name.
|
|
8
|
+
*
|
|
9
|
+
* @type {Record<string, (argv: string[]) => void>}
|
|
10
|
+
*/
|
|
11
|
+
const MODS = {
|
|
12
|
+
esm
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const HELP = `Usage: npx @bpmn-io/codemods <mod> [options]
|
|
16
|
+
|
|
17
|
+
A collection of codemods for the bpmn.io ecosystem.
|
|
18
|
+
|
|
19
|
+
Mods:
|
|
20
|
+
${Object.keys(MODS).map((name) => ` ${name}`).join('\n')}
|
|
21
|
+
|
|
22
|
+
Run a mod with --help for its options, e.g.:
|
|
23
|
+
npx @bpmn-io/codemods esm --help
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
main(process.argv.slice(2));
|
|
27
|
+
|
|
28
|
+
function main(argv) {
|
|
29
|
+
const [ mod, ...rest ] = argv;
|
|
30
|
+
|
|
31
|
+
if (!mod || mod === '--help' || mod === '-h') {
|
|
32
|
+
process.stdout.write(HELP);
|
|
33
|
+
process.exitCode = mod ? 0 : 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const handler = MODS[mod];
|
|
38
|
+
|
|
39
|
+
if (!handler) {
|
|
40
|
+
process.stderr.write(`Unknown mod: ${mod}\n\n${HELP}`);
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
handler(rest);
|
|
46
|
+
}
|
package/esm/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# esm codemod
|
|
2
|
+
|
|
3
|
+
Appends explicit extensions to `import` / `export ... from` sources and dynamic
|
|
4
|
+
`import('...')` calls so they resolve under **native ES module resolution**.
|
|
5
|
+
|
|
6
|
+
This is useful when migrating to a package that ships "ESM only". For example,
|
|
7
|
+
[diagram-js](https://github.com/bpmn-io/diagram-js) used to allow extension-less
|
|
8
|
+
deep imports of its utilities; the ESM-only releases require an explicit
|
|
9
|
+
extension:
|
|
10
|
+
|
|
11
|
+
```diff
|
|
12
|
+
- import { getParents } from 'diagram-js/lib/util/Elements';
|
|
13
|
+
+ import { getParents } from 'diagram-js/lib/util/Elements.js';
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The mod is not tied to diagram-js — it works for any relative or package import
|
|
17
|
+
whose target file exists once an extension is appended.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx @bpmn-io/codemods esm <dir|file>
|
|
23
|
+
|
|
24
|
+
# preview without writing
|
|
25
|
+
npx @bpmn-io/codemods esm --dry-run src
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Run it from your project root so that `node_modules` (the installed,
|
|
29
|
+
already-migrated package) is reachable for resolution.
|
|
30
|
+
|
|
31
|
+
The command exits with code `1` if any import could not be resolved, so it can
|
|
32
|
+
be used as a CI guard.
|
|
33
|
+
|
|
34
|
+
## What it does
|
|
35
|
+
|
|
36
|
+
For every `.js`, `.jsx`, `.mjs`, `.cjs`, `.ts` and `.tsx` file below the target
|
|
37
|
+
(ignoring `node_modules` and `.git`):
|
|
38
|
+
|
|
39
|
+
1. Each static `import` / `export ... from` source, and every dynamic
|
|
40
|
+
`import('...')` call with a string-literal argument, is checked against ES
|
|
41
|
+
module resolution.
|
|
42
|
+
2. If the specifier does not resolve as written but does once an extension is
|
|
43
|
+
appended, the extension is added.
|
|
44
|
+
- Relative and bare (`node_modules`) specifiers are both handled.
|
|
45
|
+
- TypeScript / JSX sources are written with a `.js` specifier (NodeNext
|
|
46
|
+
convention), even though the file on disk is `.ts`/`.tsx`.
|
|
47
|
+
3. Specifiers that already carry an extension, and bare package roots
|
|
48
|
+
(e.g. `diagram-js`, `react`), are left untouched.
|
|
49
|
+
4. Anything that still cannot be resolved (e.g. a directory import that would
|
|
50
|
+
need `/index.js`, or a genuinely missing file) is **reported** for manual
|
|
51
|
+
review — never silently changed.
|
|
52
|
+
|
|
53
|
+
Only the source string is edited; surrounding formatting is preserved exactly.
|
|
54
|
+
|
|
55
|
+
## Programmatic API
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
import { migrate } from '@bpmn-io/codemods/esm/lib/migrate.js';
|
|
59
|
+
|
|
60
|
+
const report = migrate('src', { dryRun: true });
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Out of scope
|
|
64
|
+
|
|
65
|
+
By design, the mod does **not**:
|
|
66
|
+
|
|
67
|
+
- rewrite `require()` calls,
|
|
68
|
+
- rewrite dynamic imports with a non-literal argument (`import(name)`),
|
|
69
|
+
- add `/index.js` for directory imports,
|
|
70
|
+
- guess at unresolvable imports — these are reported instead.
|
package/esm/lib/cli.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { migrate } from './migrate.js';
|
|
4
|
+
|
|
5
|
+
const HELP = `Usage: npx @bpmn-io/codemods esm [options] <dir|file>
|
|
6
|
+
|
|
7
|
+
Append explicit extensions to import/export sources so they resolve under
|
|
8
|
+
native ES module resolution (e.g. when migrating to a package that ships
|
|
9
|
+
"ESM only", such as diagram-js).
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
-d, --dry-run Report changes without writing files
|
|
13
|
+
-h, --help Show this help
|
|
14
|
+
|
|
15
|
+
Exit code is 1 when any import could not be resolved.
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run the `esm` codemod CLI.
|
|
20
|
+
*
|
|
21
|
+
* @param {string[]} argv - arguments after the mod name
|
|
22
|
+
*/
|
|
23
|
+
export function run(argv) {
|
|
24
|
+
const dryRun = argv.includes('--dry-run') || argv.includes('-d');
|
|
25
|
+
const help = argv.includes('--help') || argv.includes('-h');
|
|
26
|
+
|
|
27
|
+
if (help) {
|
|
28
|
+
process.stdout.write(HELP);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const target = argv.find((arg) => !arg.startsWith('-')) || process.cwd();
|
|
33
|
+
|
|
34
|
+
const report = migrate(target, { dryRun });
|
|
35
|
+
|
|
36
|
+
print(report, dryRun);
|
|
37
|
+
|
|
38
|
+
process.exitCode = report.unresolved.length || report.errors.length ? 1 : 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function print(report, dryRun) {
|
|
42
|
+
const rel = (file) => path.relative(process.cwd(), file) || file;
|
|
43
|
+
|
|
44
|
+
if (report.rewrites.length) {
|
|
45
|
+
process.stdout.write(`\n${dryRun ? 'Would rewrite' : 'Rewrote'} imports:\n`);
|
|
46
|
+
|
|
47
|
+
for (const { file, from, to, line } of report.rewrites) {
|
|
48
|
+
process.stdout.write(` ${rel(file)}:${line} ${from} -> ${to}\n`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (report.unresolved.length) {
|
|
53
|
+
process.stdout.write('\nCould not resolve (please check manually):\n');
|
|
54
|
+
|
|
55
|
+
for (const { file, specifier, line } of report.unresolved) {
|
|
56
|
+
process.stdout.write(` ${rel(file)}:${line} ${specifier}\n`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (report.errors.length) {
|
|
61
|
+
process.stdout.write('\nFailed to parse:\n');
|
|
62
|
+
|
|
63
|
+
for (const { file, message } of report.errors) {
|
|
64
|
+
process.stdout.write(` ${rel(file)} ${message}\n`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process.stdout.write(
|
|
69
|
+
`\n${report.filesScanned} file(s) scanned, ` +
|
|
70
|
+
`${report.filesChanged} changed, ` +
|
|
71
|
+
`${report.importsRewritten} import(s) rewritten, ` +
|
|
72
|
+
`${report.unresolved.length} unresolved.\n`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { transform } from './transform.js';
|
|
5
|
+
|
|
6
|
+
const SOURCE_EXTENSIONS = new Set([ '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx' ]);
|
|
7
|
+
const IGNORED_DIRECTORIES = new Set([ 'node_modules', '.git' ]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} Report
|
|
11
|
+
* @property {number} filesScanned
|
|
12
|
+
* @property {number} filesChanged
|
|
13
|
+
* @property {number} importsRewritten
|
|
14
|
+
* @property {Array<{ file: string, from: string, to: string, line: number }>} rewrites
|
|
15
|
+
* @property {Array<{ file: string, specifier: string, line: number }>} unresolved
|
|
16
|
+
* @property {Array<{ file: string, message: string }>} errors
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Recursively migrate all JS/TS files below `target` (or a single file),
|
|
21
|
+
* appending explicit extensions to imports that would otherwise not resolve
|
|
22
|
+
* under native ES module resolution.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} target - directory or file to migrate
|
|
25
|
+
* @param {{ dryRun?: boolean }} [options]
|
|
26
|
+
*
|
|
27
|
+
* @return {Report}
|
|
28
|
+
*/
|
|
29
|
+
export function migrate(target, options = {}) {
|
|
30
|
+
const { dryRun = false } = options;
|
|
31
|
+
|
|
32
|
+
const report = {
|
|
33
|
+
filesScanned: 0,
|
|
34
|
+
filesChanged: 0,
|
|
35
|
+
importsRewritten: 0,
|
|
36
|
+
rewrites: [],
|
|
37
|
+
unresolved: [],
|
|
38
|
+
errors: []
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
for (const file of collectFiles(path.resolve(target))) {
|
|
42
|
+
report.filesScanned++;
|
|
43
|
+
|
|
44
|
+
const code = fs.readFileSync(file, 'utf8');
|
|
45
|
+
const result = transform(code, file);
|
|
46
|
+
|
|
47
|
+
if (result.error) {
|
|
48
|
+
report.errors.push({ file, message: result.error.message });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const u of result.unresolved) {
|
|
53
|
+
report.unresolved.push({ file, ...u });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (result.changed) {
|
|
57
|
+
report.filesChanged++;
|
|
58
|
+
report.importsRewritten += result.changes.length;
|
|
59
|
+
|
|
60
|
+
for (const c of result.changes) {
|
|
61
|
+
report.rewrites.push({ file, ...c });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!dryRun) {
|
|
65
|
+
fs.writeFileSync(file, result.code);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return report;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function* collectFiles(target) {
|
|
74
|
+
const stat = fs.statSync(target);
|
|
75
|
+
|
|
76
|
+
if (stat.isFile()) {
|
|
77
|
+
yield target;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
|
|
82
|
+
const full = path.join(target, entry.name);
|
|
83
|
+
|
|
84
|
+
if (entry.isDirectory()) {
|
|
85
|
+
if (!IGNORED_DIRECTORIES.has(entry.name)) {
|
|
86
|
+
yield* collectFiles(full);
|
|
87
|
+
}
|
|
88
|
+
} else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
|
89
|
+
yield full;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extensions that, when already present on a specifier, mean the import is
|
|
6
|
+
* explicit and must be left untouched (module sources as well as assets that
|
|
7
|
+
* are handled by bundlers, never by ES module resolution).
|
|
8
|
+
*/
|
|
9
|
+
const KNOWN_EXTENSIONS = new Set([
|
|
10
|
+
'.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.json', '.node',
|
|
11
|
+
'.css', '.scss', '.sass', '.less', '.styl',
|
|
12
|
+
'.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.wasm',
|
|
13
|
+
'.html', '.xml', '.md', '.txt', '.vue', '.bpmn', '.dmn', '.form'
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extensions tried for relative imports, in priority order, mapped to the
|
|
18
|
+
* extension that should be written into the specifier.
|
|
19
|
+
*
|
|
20
|
+
* TypeScript / JSX source files are resolved with `.js` specifiers (the
|
|
21
|
+
* NodeNext / "ESM only" convention), even though the file on disk is `.ts`.
|
|
22
|
+
*/
|
|
23
|
+
const RELATIVE_CANDIDATES = [
|
|
24
|
+
{ file: '.js', write: '.js' },
|
|
25
|
+
{ file: '.jsx', write: '.js' },
|
|
26
|
+
{ file: '.ts', write: '.js' },
|
|
27
|
+
{ file: '.tsx', write: '.js' },
|
|
28
|
+
{ file: '.mjs', write: '.mjs' },
|
|
29
|
+
{ file: '.cjs', write: '.cjs' },
|
|
30
|
+
{ file: '.json', write: '.json' }
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extensions tried for bare (node_modules) imports. Published packages ship
|
|
35
|
+
* compiled files, so we only look for runtime extensions.
|
|
36
|
+
*/
|
|
37
|
+
const BARE_CANDIDATES = [
|
|
38
|
+
{ file: '.js', write: '.js' },
|
|
39
|
+
{ file: '.mjs', write: '.mjs' },
|
|
40
|
+
{ file: '.cjs', write: '.cjs' },
|
|
41
|
+
{ file: '.json', write: '.json' }
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Decide how a single import specifier should be migrated for native ES
|
|
46
|
+
* module resolution.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} specifier - the raw import source, e.g. `diagram-js/lib/util/Elements`
|
|
49
|
+
* @param {string} fromFile - absolute path of the importing file
|
|
50
|
+
*
|
|
51
|
+
* @return {{ status: ('skip'|'rewrite'|'unresolved'), specifier: string }}
|
|
52
|
+
* `skip` if already resolvable, `rewrite` with the new specifier if an
|
|
53
|
+
* extension was appended, `unresolved` if no `.js`-style extension resolves.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveImport(specifier, fromFile) {
|
|
56
|
+
const bare = isBare(specifier);
|
|
57
|
+
|
|
58
|
+
// bare package roots (e.g. 'react', 'diagram-js') resolve via main/exports
|
|
59
|
+
if (bare && isBareRoot(specifier)) {
|
|
60
|
+
return { status: 'skip', specifier };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// already carries an explicit extension
|
|
64
|
+
if (KNOWN_EXTENSIONS.has(specifierExtension(specifier))) {
|
|
65
|
+
return { status: 'skip', specifier };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rewritten = bare
|
|
69
|
+
? resolveBare(specifier, fromFile)
|
|
70
|
+
: resolveRelative(specifier, fromFile);
|
|
71
|
+
|
|
72
|
+
if (rewritten) {
|
|
73
|
+
return { status: 'rewrite', specifier: rewritten };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { status: 'unresolved', specifier };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveRelative(specifier, fromFile) {
|
|
80
|
+
const base = path.resolve(path.dirname(fromFile), specifier);
|
|
81
|
+
|
|
82
|
+
return resolveCandidates(base, specifier, RELATIVE_CANDIDATES);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveBare(specifier, fromFile) {
|
|
86
|
+
const { pkg, subpath } = parseBare(specifier);
|
|
87
|
+
|
|
88
|
+
const pkgDir = findPackageDir(pkg, path.dirname(fromFile));
|
|
89
|
+
|
|
90
|
+
if (!pkgDir) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return resolveCandidates(path.join(pkgDir, subpath), specifier, BARE_CANDIDATES);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolveCandidates(basePath, specifier, candidates) {
|
|
98
|
+
for (const { file, write } of candidates) {
|
|
99
|
+
if (isFile(basePath + file)) {
|
|
100
|
+
return specifier + write;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Locate a package directory by walking up the `node_modules` chain, starting
|
|
109
|
+
* from the importing file's directory.
|
|
110
|
+
*/
|
|
111
|
+
function findPackageDir(pkg, fromDir) {
|
|
112
|
+
let dir = fromDir;
|
|
113
|
+
|
|
114
|
+
for (;;) {
|
|
115
|
+
const candidate = path.join(dir, 'node_modules', pkg);
|
|
116
|
+
|
|
117
|
+
if (isDirectory(candidate)) {
|
|
118
|
+
return candidate;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const parent = path.dirname(dir);
|
|
122
|
+
|
|
123
|
+
if (parent === dir) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
dir = parent;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseBare(specifier) {
|
|
132
|
+
if (specifier.startsWith('@')) {
|
|
133
|
+
const parts = specifier.split('/');
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
pkg: parts.slice(0, 2).join('/'),
|
|
137
|
+
subpath: parts.slice(2).join('/')
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const idx = specifier.indexOf('/');
|
|
142
|
+
|
|
143
|
+
return idx === -1
|
|
144
|
+
? { pkg: specifier, subpath: '' }
|
|
145
|
+
: { pkg: specifier.slice(0, idx), subpath: specifier.slice(idx + 1) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isBare(specifier) {
|
|
149
|
+
return !specifier.startsWith('.') && !specifier.startsWith('/');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isBareRoot(specifier) {
|
|
153
|
+
return parseBare(specifier).subpath === '';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function specifierExtension(specifier) {
|
|
157
|
+
const segment = specifier.split('/').pop();
|
|
158
|
+
const dot = segment.lastIndexOf('.');
|
|
159
|
+
|
|
160
|
+
return dot <= 0 ? '' : segment.slice(dot).toLowerCase();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isFile(p) {
|
|
164
|
+
try {
|
|
165
|
+
return fs.statSync(p).isFile();
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isDirectory(p) {
|
|
172
|
+
try {
|
|
173
|
+
return fs.statSync(p).isDirectory();
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { parse } from '@babel/parser';
|
|
4
|
+
|
|
5
|
+
import { resolveImport } from './resolve.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Rewrite the import/export sources of a single file so that they resolve
|
|
9
|
+
* under native ES module resolution.
|
|
10
|
+
*
|
|
11
|
+
* Only the source string literals are touched; the rest of the file is left
|
|
12
|
+
* byte-for-byte unchanged.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} code - the file contents
|
|
15
|
+
* @param {string} filename - absolute path of the file (used for resolution)
|
|
16
|
+
*
|
|
17
|
+
* @return {{
|
|
18
|
+
* code: string,
|
|
19
|
+
* changed: boolean,
|
|
20
|
+
* changes: Array<{ from: string, to: string, line: number }>,
|
|
21
|
+
* unresolved: Array<{ specifier: string, line: number }>,
|
|
22
|
+
* error: (Error|null)
|
|
23
|
+
* }}
|
|
24
|
+
*/
|
|
25
|
+
export function transform(code, filename) {
|
|
26
|
+
let ast;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
ast = parse(code, {
|
|
30
|
+
sourceType: 'module',
|
|
31
|
+
allowReturnOutsideFunction: true,
|
|
32
|
+
plugins: pluginsFor(filename)
|
|
33
|
+
});
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return { code, changed: false, changes: [], unresolved: [], error };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const edits = [];
|
|
39
|
+
const changes = [];
|
|
40
|
+
const unresolved = [];
|
|
41
|
+
|
|
42
|
+
for (const source of importSources(ast)) {
|
|
43
|
+
const result = resolveImport(source.value, filename);
|
|
44
|
+
const line = source.loc.start.line;
|
|
45
|
+
|
|
46
|
+
if (result.status === 'rewrite') {
|
|
47
|
+
const quote = code[source.start];
|
|
48
|
+
|
|
49
|
+
edits.push({
|
|
50
|
+
start: source.start,
|
|
51
|
+
end: source.end,
|
|
52
|
+
text: quote + result.specifier + quote
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
changes.push({ from: source.value, to: result.specifier, line });
|
|
56
|
+
} else if (result.status === 'unresolved') {
|
|
57
|
+
unresolved.push({ specifier: source.value, line });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
code: applyEdits(code, edits),
|
|
63
|
+
changed: edits.length > 0,
|
|
64
|
+
changes,
|
|
65
|
+
unresolved,
|
|
66
|
+
error: null
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Collect the source string literals of all static `import`/`export ... from`
|
|
72
|
+
* declarations as well as dynamic `import('...')` calls with a string argument.
|
|
73
|
+
*
|
|
74
|
+
* Dynamic imports with a non-literal argument (template, variable) cannot be
|
|
75
|
+
* resolved statically and are therefore ignored.
|
|
76
|
+
*/
|
|
77
|
+
function importSources(ast) {
|
|
78
|
+
const sources = [];
|
|
79
|
+
|
|
80
|
+
for (const node of walk(ast.program)) {
|
|
81
|
+
if (
|
|
82
|
+
(node.type === 'ImportDeclaration' ||
|
|
83
|
+
node.type === 'ExportNamedDeclaration' ||
|
|
84
|
+
node.type === 'ExportAllDeclaration') &&
|
|
85
|
+
node.source
|
|
86
|
+
) {
|
|
87
|
+
sources.push(node.source);
|
|
88
|
+
} else if (node.type === 'CallExpression' && node.callee.type === 'Import') {
|
|
89
|
+
const [ arg ] = node.arguments;
|
|
90
|
+
|
|
91
|
+
if (arg && arg.type === 'StringLiteral') {
|
|
92
|
+
sources.push(arg);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return sources;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Depth-first traversal over every AST node, dependency-free.
|
|
102
|
+
*/
|
|
103
|
+
function* walk(node) {
|
|
104
|
+
yield node;
|
|
105
|
+
|
|
106
|
+
for (const key in node) {
|
|
107
|
+
const value = node[key];
|
|
108
|
+
|
|
109
|
+
if (Array.isArray(value)) {
|
|
110
|
+
for (const child of value) {
|
|
111
|
+
if (isNode(child)) {
|
|
112
|
+
yield* walk(child);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} else if (isNode(value)) {
|
|
116
|
+
yield* walk(value);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isNode(value) {
|
|
122
|
+
return value !== null && typeof value === 'object' && typeof value.type === 'string';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Apply replacements to the source string. Edits are applied back-to-front so
|
|
127
|
+
* that earlier offsets stay valid.
|
|
128
|
+
*/
|
|
129
|
+
function applyEdits(code, edits) {
|
|
130
|
+
let result = code;
|
|
131
|
+
|
|
132
|
+
for (const { start, end, text } of edits.sort((a, b) => b.start - a.start)) {
|
|
133
|
+
result = result.slice(0, start) + text + result.slice(end);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function pluginsFor(filename) {
|
|
140
|
+
const ext = path.extname(filename).toLowerCase();
|
|
141
|
+
|
|
142
|
+
if (ext === '.ts') {
|
|
143
|
+
return [ 'typescript', 'decorators-legacy' ];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (ext === '.tsx') {
|
|
147
|
+
return [ 'typescript', 'jsx', 'decorators-legacy' ];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return [ 'jsx', 'decorators-legacy' ];
|
|
151
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bpmn-io/codemods",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A collection of codemods for the bpmn.io ecosystem.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"codemods": "bin/codemods.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"all": "npm run test",
|
|
11
|
+
"test": "mocha"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"esm/lib",
|
|
16
|
+
"esm/README.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@babel/parser": "^7.24.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"chai": "^5.1.0",
|
|
30
|
+
"mocha": "^10.4.0"
|
|
31
|
+
}
|
|
32
|
+
}
|