Skip to content

Commit 8c68e88

Browse files
committed
v0.1.0: Initial release 🍾
0 parents  commit 8c68e88

File tree

7 files changed

+9943
-0
lines changed

7 files changed

+9943
-0
lines changed

.eslintrc.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('bitumen/configuration/eslint');

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules/

LICENSE

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
BSD 2-Clause License
2+
3+
Copyright (c) Nick Chevsky
4+
5+
Redistribution and use in source and binary forms, with or without
6+
modification, are permitted provided that the following conditions are met:
7+
8+
1. Redistributions of source code must retain the above copyright notice, this
9+
list of conditions and the following disclaimer.
10+
11+
2. Redistributions in binary form must reproduce the above copyright notice,
12+
this list of conditions and the following disclaimer in the documentation
13+
and/or other materials provided with the distribution.
14+
15+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Overview
2+
3+
`renova` patches third-party packages with non-fully-ESM-compliant source and/or TypeScript declaration files (e.g. `@apollo/client`) by appending explicit file names and extensions to unqualified `export` and `import` statements, facilitating `nodenext`/`node16`+ module resolution of dependencies with legacy exports.
4+
5+
For example, given the following source tree:
6+
7+
- `directory/`
8+
- `index.js`
9+
- `file.js`
10+
11+
Unqualified exports and imports are corrected as follows:
12+
13+
## Input
14+
```js
15+
export {foo} from './directory';
16+
import bar from './file';
17+
```
18+
19+
## Output
20+
```js
21+
export {foo} from './directory/index.js';
22+
import bar from './file.js';
23+
```
24+
25+
# Usage
26+
27+
## Automatic
28+
29+
📍 `package.json`
30+
31+
```json
32+
{
33+
"scripts": {
34+
"dependencies": "renova @apollo/client"
35+
}
36+
}
37+
```
38+
39+
This will patch `@apollo/client`'s `.d.ts` files whenever dependencies are installed or upgraded.
40+
41+
💡 Note that, when run from the `dependencies` lifecycle script, no output may be printed. In contrast, `postinstall` does allow output but is only executed when installing _all_ dependencies; not when installing or upgrading one or more specific packages.
42+
43+
## Manual
44+
```shell
45+
$ npx renova [--dry-run] [--extensions=<extension>,...] [--verbose] <package> ...
46+
47+
<package>: Name of a package under ./node_modules to patch, e.g. '@apollo/client'.
48+
49+
--dry-run: Print potential outcome without altering files. Implies --verbose.
50+
--extensions: Comma-separated list of file name extensions to process. Defaults to '.d.ts'.
51+
--verbose: Print all matching exports and imports.
52+
```
53+
54+
# Example
55+
```shell
56+
# first-time run
57+
$ npx renova --verbose @apollo/client
58+
./node_modules/@apollo/client/cache/core/cache.d.ts
59+
🛠️ ../../utilities → ../../utilities/index.js
60+
🛠️ ./types/DataProxy → ./types/DataProxy.js
61+
🛠️ ./types/Cache → ./types/Cache.js
62+
...
63+
64+
# safe to run on an already-patched package
65+
$ npx renova --verbose @apollo/client
66+
./node_modules/@apollo/client/cache/core/cache.d.ts
67+
✔️ ../../utilities/index.js
68+
✔️ ./types/DataProxy.js
69+
✔️ ./types/Cache.js
70+
...
71+
```

index.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
#!/usr/bin/env node
2+
3+
import {open, opendir, stat} from 'node:fs/promises';
4+
5+
import {parse, print} from 'recast';
6+
import typeScriptParser from 'recast/parsers/typescript.js';
7+
8+
const options = {dryRun: false, extensions: ['.d.ts'], packages: new Set(), verbose: false};
9+
10+
/**
11+
* Recursively discovers matching files and directories in the given path and corrects any
12+
* unqualified exports and imports by appending the appropriate file names and extensions.
13+
*/
14+
async function processFiles(rootPath) {
15+
for await (const directoryEntry of await opendir(rootPath)) {
16+
/** Whether the current file contained exports/imports that have been corrected. */
17+
let hasFileBeenCorrected = false;
18+
/** Whether the path to the current file has so far been logged. */
19+
let hasPathBeenPrinted = false;
20+
/** Absolute path to the current directory or file. */
21+
const path = `${rootPath}/${directoryEntry.name}`;
22+
23+
/**
24+
* @param {string} message
25+
* @param {'error' | 'info'} severity
26+
*/
27+
const log = (message, severity = 'info') => {
28+
if (severity == 'info' && !options.verbose) return;
29+
30+
if (!hasPathBeenPrinted) {
31+
hasPathBeenPrinted = true;
32+
console.log(path);
33+
}
34+
35+
(severity == 'error' ? console.error : console.log)('\t', message);
36+
};
37+
38+
if (directoryEntry.isDirectory() && directoryEntry.name != 'node_modules') {
39+
await processFiles(path);
40+
} else if (options.extensions.some((extension) => directoryEntry.name.endsWith(extension))) {
41+
const file = await open(path, 'r+');
42+
43+
try {
44+
const ast = parse((await file.readFile()).toString(), {parser: typeScriptParser});
45+
46+
for (const node of ast.program.body) {
47+
if ((node.type.startsWith('Export') || node.type.startsWith('Import'))
48+
&& node.source?.value.startsWith('.')) {
49+
const unqualifiedPath = node.source.value;
50+
51+
/** @type {Awaited<ReturnType<stat>>} */
52+
let stats;
53+
for (const suffix of ['', '/index.js', '.js']) {
54+
try {
55+
const qualifiedPath = `${unqualifiedPath}${suffix}`;
56+
stats = await stat(`${rootPath}/${qualifiedPath}`);
57+
if (!stats.isFile() /* is directory */) continue; // try next suffix
58+
59+
if (suffix) { // needs qualification
60+
log(`🛠️ ${unqualifiedPath}${qualifiedPath}`);
61+
node.source.value = qualifiedPath;
62+
hasFileBeenCorrected = true;
63+
} else { // already fully qualified
64+
log(`✔️ ${qualifiedPath}`);
65+
}
66+
break;
67+
} catch (error) {
68+
if (error.code == 'ENOENT' /* no such file or directory */) continue; // try next suffix
69+
throw error;
70+
}
71+
}
72+
if (!stats) log(`❌ ${unqualifiedPath}`, 'error');
73+
}
74+
}
75+
76+
// write modified file
77+
if (hasFileBeenCorrected && !options.dryRun) {
78+
await file.truncate();
79+
await file.write(print(ast, {quote: 'single'}).code, 0);
80+
}
81+
} finally {
82+
await file.close();
83+
}
84+
}
85+
}
86+
}
87+
88+
for (const argument of process.argv.slice(2)) {
89+
switch (argument) {
90+
case '--dry-run': options.dryRun = options.verbose = true; break;
91+
case '--verbose': options.verbose = true; break;
92+
default:
93+
if (argument.startsWith('--extensions=')) {
94+
const extensions = argument.split('=')[1].split(',').filter((extension) => Boolean(extension.trim()));
95+
if (extensions.length) options.extensions = extensions;
96+
} else if (!argument.startsWith('--') && argument.match(/^[^._][^~)('!*]{1,214}$/)) {
97+
options.packages.add(argument);
98+
}
99+
}
100+
}
101+
102+
if (options.packages.size) {
103+
options.packages.forEach(async (packageName) => await processFiles(`./node_modules/${packageName}`));
104+
} else {
105+
console.log(`npx renova [--dry-run] [--extensions=<extension>,...] [--verbose] <package> ...
106+
107+
<package>: Name of a package under ./node_modules to patch, e.g. '@apollo/client'.
108+
109+
--dry-run: Print potential outcome without altering files. Implies --verbose.
110+
--extensions: Comma-separated list of file name extensions to process. Defaults to '.d.ts'.
111+
--verbose: Print all matching exports and imports.`);
112+
}

0 commit comments

Comments
 (0)