Skip to content

Commit c37a382

Browse files
feat: support imports map in package.json
1 parent 72f39d8 commit c37a382

File tree

12 files changed

+562
-36
lines changed

12 files changed

+562
-36
lines changed

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,22 @@ This automatically bundles all matching source files. For example:
114114
> [!IMPORTANT]
115115
> Wildcard patterns must include a file extension (e.g., `.mjs`, `.cjs`)
116116
117+
#### Subpath Imports
118+
119+
_Pkgroll_ supports building entry-points defined in [`package.json#imports` (Node.js subpath imports)](https://nodejs.org/api/packages.html#subpath-imports), including conditional imports:
120+
121+
```json
122+
{
123+
"imports": {
124+
"#my-pkg": "./dist/index.js",
125+
"#env": {
126+
"node": "./dist/env.node.js",
127+
"default": "./dist/env.browser.js"
128+
}
129+
}
130+
}
131+
```
132+
117133
### Output formats
118134
_Pkgroll_ detects the format for each entry-point based on the file extension or the `package.json` property it's placed in, using the [same lookup logic as Node.js](https://nodejs.org/api/packages.html#determining-module-system).
119135

@@ -171,9 +187,6 @@ You can configure aliases using the [import map](https://nodejs.org/api/packages
171187

172188
In Node.js, import mappings must start with `#` to indicate an internal [subpath import](https://nodejs.org/api/packages.html#subpath-imports). However, _Pkgroll_ allows defining aliases **without** the `#` prefix.
173189

174-
> [!NOTE]
175-
> While Node.js supports conditional imports (e.g., different paths for Node.js vs. browsers), _Pkgroll_ does not.
176-
177190
Example:
178191

179192
```json5

src/rollup/configs/dts.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { TsConfigResult } from 'get-tsconfig';
33
import { nodeBuiltins } from '../plugins/node-builtins.js';
44
import { resolveJsToTs } from '../plugins/resolve-js-to-ts.js';
55
import { resolveTsconfigPaths } from '../plugins/resolve-tsconfig-paths.js';
6+
import { externalPkgImports } from '../plugins/external-pkg-imports.js';
67
import type { Options, Output } from '../types.js';
78

89
export const getDtsConfig = async (
@@ -24,6 +25,7 @@ export const getDtsConfig = async (
2425
input: {} as Record<string, string>,
2526
preserveEntrySignatures: 'strict' as const,
2627
plugins: [
28+
externalPkgImports(),
2729
nodeBuiltins(options),
2830
...(
2931
tsconfig

src/rollup/configs/pkg.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { resolveTsconfigPaths } from '../plugins/resolve-tsconfig-paths.js';
1515
import { stripHashbang } from '../plugins/strip-hashbang.js';
1616
import { esmInjectCreateRequire } from '../plugins/esm-inject-create-require.js';
1717
import { nativeModules } from '../plugins/native-modules.js';
18+
import { externalPkgImports } from '../plugins/external-pkg-imports.js';
1819
import type { Options, Output } from '../types.js';
1920
import type { EntryPointValid } from '../../utils/get-build-entry-points/types.js';
2021
import { cjsAnnotateExports } from '../plugins/cjs-annotate-exports.js';
@@ -56,6 +57,7 @@ export const getPkgConfig = (
5657
alias({
5758
entries: aliases,
5859
}),
60+
externalPkgImports(),
5961
nodeResolve({
6062
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'],
6163
exportConditions: options.exportCondition,
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import path from 'node:path';
2+
import fs from 'node:fs';
3+
import type { Plugin } from 'rollup';
4+
import { slash } from '../../utils/normalize-path.js';
5+
6+
/**
7+
* Externalize package imports from the current package so Node.js
8+
* resolves them at runtime using package.json#imports
9+
* https://nodejs.org/api/packages.html#subpath-imports
10+
*/
11+
export const externalPkgImports = (): Plugin => {
12+
// Resolve to canonical path to handle Windows 8.3 short paths
13+
const cwd = fs.realpathSync.native(process.cwd());
14+
return {
15+
name: 'external-pkg-imports',
16+
resolveId(id, importer) {
17+
if (id[0] !== '#') {
18+
return null;
19+
}
20+
21+
if (importer) {
22+
// Get path relative to cwd
23+
const relativePath = slash(path.relative(cwd, importer));
24+
// Check if importer is from a dependency (has /node_modules/ path segment)
25+
const pathSegments = relativePath.split('/');
26+
if (pathSegments.includes('node_modules')) {
27+
// Let Node-resolver handle imports maps from dependencies
28+
return null;
29+
}
30+
}
31+
32+
// Import is from current package, externalize it
33+
return {
34+
id,
35+
external: true,
36+
};
37+
},
38+
};
39+
};

src/rollup/plugins/native-modules.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from 'node:fs/promises';
22
import path from 'node:path';
33
import type { Plugin } from 'rollup';
4+
import { slash } from '../../utils/normalize-path.js';
45

56
const PREFIX = '\0natives:';
67

@@ -75,9 +76,9 @@ export const nativeModules = (
7576
}
7677

7778
// Generate the require path relative to the final bundle directory
78-
const relativePath = `./${path.relative(distDirectory, destinationPath)}`;
79+
const relativePath = `./${slash(path.relative(distDirectory, destinationPath))}`;
7980

80-
return `export default require("${relativePath.replaceAll('\\', '/')}");`;
81+
return `export default require("${relativePath}");`;
8182
},
8283

8384
generateBundle: async () => {

src/utils/get-build-entry-points/expand-exports-wildcards.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ export const expandBuildOutputWildcards = async (
4848
const expandedResults = await Promise.all(
4949
buildOutputs.map(async (output) => {
5050
if (
51-
// Only process exports wildcards (not main/module/types/bin/cli)
51+
// Only process exports/imports wildcards (not main/module/types/bin/cli)
5252
typeof output.source === 'string'
53-
|| output.source.path[0] !== 'exports'
53+
|| (output.source.path[0] !== 'exports' && output.source.path[0] !== 'imports')
5454

5555
// Skip non-wildcard entries
5656
|| !output.outputPath.includes('*')

src/utils/get-build-entry-points/get-pkg-entry-points.ts

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,76 @@
11
import type { PackageJson } from 'type-fest';
22
import { normalizePath } from '../normalize-path.js';
3-
import type { PackageType, BuildOutput, ObjectPath } from './types.js';
3+
import type {
4+
PackageType, BuildOutput, ObjectPath, PackageMapType,
5+
} from './types.js';
46
import { getFileType, isPath } from './utils.js';
57

68
const getConditions = (
79
fromPath: ObjectPath,
810
) => fromPath.slice(1).filter(part => (typeof part === 'string' && part[0] !== '.')) as string[];
911

10-
const parseExportsMap = (
11-
exportMap: PackageJson['exports'],
12+
const parsePackageMap = (
13+
packageMap: PackageJson['exports'] | PackageJson['imports'],
1214
packageType: PackageType,
13-
packagePath: ObjectPath = ['exports'],
15+
mapType: PackageMapType,
16+
packagePath: ObjectPath = [mapType],
1417
): BuildOutput[] => {
15-
if (!exportMap) {
18+
if (!packageMap) {
1619
return [];
1720
}
1821

19-
if (typeof exportMap === 'string') {
22+
if (typeof packageMap === 'string') {
2023
return [{
2124
source: {
2225
type: 'package.json',
2326
path: [...packagePath],
2427
},
25-
type: 'exportmap',
28+
type: mapType,
2629
conditions: [],
27-
format: getFileType(exportMap) || packageType,
28-
outputPath: normalizePath(exportMap),
30+
format: getFileType(packageMap) || packageType,
31+
outputPath: normalizePath(packageMap),
2932
}];
3033
}
3134

32-
if (Array.isArray(exportMap)) {
33-
return exportMap.flatMap(
34-
(exportPath, index) => {
35+
if (Array.isArray(packageMap)) {
36+
return packageMap.flatMap(
37+
(mapPath, index) => {
3538
const from = [...packagePath, index];
3639
return (
37-
typeof exportPath === 'string'
40+
typeof mapPath === 'string'
3841
? (
39-
isPath(exportPath)
42+
isPath(mapPath)
4043
? {
4144
source: {
4245
type: 'package.json',
4346
path: [...from],
4447
},
45-
type: 'exportmap',
48+
type: mapType,
4649
conditions: getConditions(from),
47-
format: getFileType(exportPath) || packageType,
48-
outputPath: normalizePath(exportPath),
50+
format: getFileType(mapPath) || packageType,
51+
outputPath: normalizePath(mapPath),
4952
}
5053
: []
5154
)
52-
: parseExportsMap(exportPath, packageType, from)
55+
: parsePackageMap(mapPath, packageType, mapType, from)
5356
);
5457
},
5558
);
5659
}
5760

58-
return Object.entries(exportMap).flatMap(([key, value]) => {
61+
const isImports = mapType === 'imports' && packagePath.length === 1;
62+
return Object.entries(packageMap).flatMap(([key, value]) => {
63+
// For imports, only process # imports at the top level
64+
// Nested keys are export conditions (node, default, etc.)
65+
if (isImports && key[0] !== '#') {
66+
return [];
67+
}
68+
5969
const from = [...packagePath, key];
6070
if (typeof value === 'string') {
6171
const conditions = getConditions(from);
6272
const baseEntry = {
63-
type: 'exportmap' as const,
73+
type: mapType,
6474
source: {
6575
type: 'package.json' as const,
6676
path: from,
@@ -82,7 +92,7 @@ const parseExportsMap = (
8292
};
8393
}
8494

85-
return parseExportsMap(value, packageType, from);
95+
return parsePackageMap(value, packageType, mapType, from);
8696
});
8797
};
8898

@@ -162,10 +172,13 @@ export const getPkgEntryPoints = (
162172
}
163173

164174
if (packageJson.exports) {
165-
const exportMap = parseExportsMap(packageJson.exports, packageType);
166-
for (const exportEntry of exportMap) {
167-
pkgEntryPoints.push(exportEntry);
168-
}
175+
const exportMap = parsePackageMap(packageJson.exports, packageType, 'exports');
176+
pkgEntryPoints.push(...exportMap);
177+
}
178+
179+
if (packageJson.imports) {
180+
const importEntries = parsePackageMap(packageJson.imports, packageType, 'imports');
181+
pkgEntryPoints.push(...importEntries);
169182
}
170183

171184
return pkgEntryPoints;

src/utils/get-build-entry-points/types.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ export type BinaryOutput = Output<SourcePackageJson> & {
2222
type: 'binary';
2323
};
2424

25-
export type ExportMapOutput = Output<SourcePackageJson> & {
26-
type: 'exportmap';
25+
export type PackageMapType = 'exports' | 'imports';
26+
27+
export type PackageMapOutput = Output<SourcePackageJson> & {
28+
type: PackageMapType;
2729
conditions: string[];
2830
};
2931

@@ -33,7 +35,7 @@ export type LegacyOutput = Output<OutputSource> & {
3335
isExecutable?: boolean;
3436
};
3537

36-
export type BuildOutput = BinaryOutput | ExportMapOutput | LegacyOutput;
38+
export type BuildOutput = BinaryOutput | PackageMapOutput | LegacyOutput;
3739

3840
export type EntryPointValid<T extends BuildOutput = BuildOutput> = {
3941
sourcePath: string;

src/utils/normalize-path.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import path from 'node:path';
22

3+
// Convert Windows backslashes to forward slashes
4+
export const slash = (p: string) => p.replaceAll('\\', '/');
5+
36
export const normalizePath = (
47
filePath: string,
58
isDirectory?: boolean,

tests/specs/builds/dependencies.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,6 @@ export default testSuite(({ describe }, nodePath: string) => {
227227

228228
test('imports map - node', async () => {
229229
await using fixture = await createFixture(fixtureDependencyImportsMap);
230-
231230
const pkgrollProcess = await pkgroll(['--export-condition=node'], {
232231
cwd: fixture.path,
233232
nodePath,

0 commit comments

Comments
 (0)