Skip to content

Commit 3e3aa8f

Browse files
authored
Fix bundled plugin package metadata (#498)
1 parent 745c34a commit 3e3aa8f

4 files changed

Lines changed: 168 additions & 4 deletions

File tree

package-lock.json

Lines changed: 2 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
"eslint-plugin-self": "^1.2.1",
5050
"eslint-plugin-testing-library": "^7.16.2",
5151
"globals": "^17.6.0",
52-
"typescript-eslint": "^8.59.4"
52+
"typescript-eslint": "^8.59.4",
53+
"unrs-resolver": "^1.12.2"
5354
},
5455
"devDependencies": {
5556
"@types/jest": "^30.0.0",

src/bundle.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import fs from 'node:fs';
2+
import {createRequire} from 'node:module';
3+
import path from 'node:path';
4+
import {execFileSync} from 'node:child_process';
5+
6+
const requireFromTest = createRequire(__filename);
7+
8+
function buildBundle(): string {
9+
execFileSync('npm', ['run', 'build'], {
10+
cwd: path.resolve(__dirname, '..'),
11+
stdio: 'pipe',
12+
});
13+
14+
return path.resolve(__dirname, '../dist/index.cjs');
15+
}
16+
17+
describe('published bundle', () => {
18+
it('inlines metadata for bundled plugins that read their own package.json', () => {
19+
const bundlePath = buildBundle();
20+
const bundle = fs.readFileSync(bundlePath, 'utf8');
21+
22+
expect(bundle).not.toContain('cjsRequire("../package.json")');
23+
24+
const sandboxRoot = path.resolve(__dirname, '../.tmp');
25+
26+
fs.mkdirSync(sandboxRoot, {recursive: true});
27+
28+
const sandbox = fs.mkdtempSync(path.join(sandboxRoot, 'croct-eslint-plugin-'));
29+
const packageDir = path.join(sandbox, 'node_modules/@croct/eslint-plugin');
30+
const distDir = path.join(packageDir, 'dist');
31+
32+
fs.mkdirSync(distDir, {recursive: true});
33+
fs.copyFileSync(bundlePath, path.join(distDir, 'index.cjs'));
34+
fs.writeFileSync(
35+
path.join(packageDir, 'package.json'),
36+
JSON.stringify({name: '@croct/eslint-plugin', version: '0.0.0-dev', main: 'dist/index.cjs'}),
37+
);
38+
39+
const script = [
40+
`const plugin = require(${JSON.stringify(packageDir)});`,
41+
'const javascriptConfig = plugin.configs.javascript.at(-1);',
42+
'process.stdout.write(JSON.stringify(javascriptConfig.plugins[\'import-x\'].meta));',
43+
].join('\n');
44+
45+
const meta = execFileSync(
46+
'node',
47+
['-e', script],
48+
{cwd: path.resolve(__dirname, '..'), encoding: 'utf8'},
49+
);
50+
51+
const importXPackageJson = JSON.parse(
52+
fs.readFileSync(requireFromTest.resolve('eslint-plugin-import-x/package.json'), 'utf8'),
53+
) as {name: string, version: string};
54+
55+
expect(JSON.parse(meta)).toEqual({
56+
name: importXPackageJson.name,
57+
version: importXPackageJson.version,
58+
});
59+
});
60+
});

tsdown.config.mts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,115 @@
11
import { defineConfig } from 'tsdown';
2+
import fs from 'node:fs';
23
import path from 'node:path';
4+
import {parseAst} from 'rolldown/parseAst';
5+
6+
type AstNode = {
7+
type?: string;
8+
start?: number;
9+
end?: number;
10+
name?: string;
11+
value?: unknown;
12+
callee?: AstNode;
13+
arguments?: AstNode[];
14+
[key: string]: unknown;
15+
};
16+
17+
type Replacement = {
18+
start: number;
19+
end: number;
20+
value: string;
21+
};
22+
23+
function isNode(value: unknown): value is AstNode {
24+
return typeof value === 'object' && value !== null && typeof (value as AstNode).type === 'string';
25+
}
26+
27+
function isNodeArray(value: unknown): value is AstNode[] {
28+
return Array.isArray(value) && value.every(isNode);
29+
}
30+
31+
function isBundledDependency(id: string): boolean {
32+
return id.includes('/node_modules/') || id.includes('\\node_modules\\');
33+
}
34+
35+
function isPackageJsonSpecifier(value: unknown): value is string {
36+
return typeof value === 'string' && value.startsWith('.') && path.basename(value) === 'package.json';
37+
}
38+
39+
function findPackageJsonRequires(node: AstNode, visitor: (node: AstNode, specifier: string) => void): void {
40+
if (
41+
node.type === 'CallExpression'
42+
&& node.callee?.type === 'Identifier'
43+
&& (node.callee.name === 'cjsRequire' || node.callee.name === 'require')
44+
&& node.arguments?.length === 1
45+
&& node.arguments[0]?.type === 'Literal'
46+
&& isPackageJsonSpecifier(node.arguments[0].value)
47+
) {
48+
visitor(node, node.arguments[0].value);
49+
}
50+
51+
for (const value of Object.values(node)) {
52+
if (isNode(value)) {
53+
findPackageJsonRequires(value, visitor);
54+
} else if (isNodeArray(value)) {
55+
for (const child of value) {
56+
findPackageJsonRequires(child, visitor);
57+
}
58+
}
59+
}
60+
}
61+
62+
function applyReplacements(code: string, replacements: Replacement[]): string {
63+
return replacements
64+
.sort((first, second) => second.start - first.start)
65+
.reduce(
66+
(result, replacement) => (
67+
result.slice(0, replacement.start) + replacement.value + result.slice(replacement.end)
68+
),
69+
code,
70+
);
71+
}
72+
73+
function inlineBundledPackageJson() {
74+
return {
75+
name: 'inline-bundled-package-json',
76+
transform(code: string, id: string, meta: {ast?: AstNode} = {}) {
77+
if (!isBundledDependency(id) || !code.includes('package.json')) {
78+
return null;
79+
}
80+
81+
const ast = meta.ast ?? parseAst(code, null, id) as AstNode;
82+
const replacements: Replacement[] = [];
83+
84+
findPackageJsonRequires(ast, (node, specifier) => {
85+
if (node.start === undefined || node.end === undefined) {
86+
return;
87+
}
88+
89+
const packageJsonPath = path.resolve(path.dirname(id), specifier);
90+
91+
if (!fs.existsSync(packageJsonPath)) {
92+
return;
93+
}
94+
95+
replacements.push({
96+
start: node.start,
97+
end: node.end,
98+
value: JSON.stringify(JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))),
99+
});
100+
});
101+
102+
return replacements.length > 0 ? applyReplacements(code, replacements) : null;
103+
},
104+
};
105+
}
3106

4107
export default defineConfig({
5108
entry: ['src/index.ts'],
6109
format: 'cjs',
7110
dts: true,
8111
platform: 'node',
112+
plugins: [inlineBundledPackageJson()],
9113
alias: {
10114
// Redirect to a shim that statically resolves rules
11115
// (the original uses `requireindex` which breaks in bundles)

0 commit comments

Comments
 (0)