@knighted/duel 2.1.7 → 3.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 CHANGED
@@ -16,7 +16,7 @@ Tool for building a Node.js [dual package](https://nodejs.org/api/packages.html#
16
16
 
17
17
  ## Requirements
18
18
 
19
- - Node >= 20.11.0
19
+ - Node >= 22.21.1 (<23) or >= 24 (<25)
20
20
 
21
21
  ## Example
22
22
 
@@ -73,6 +73,13 @@ Assuming an `outDir` of `dist`, running the above will create `dist/esm` and `di
73
73
 
74
74
  TypeScript will throw compiler errors when using `import.meta` globals while targeting a CommonJS dual build, but _will not_ throw compiler errors when the inverse is true, i.e. using CommonJS globals (`__filename`, `__dirname`, etc.) while targeting an ES module dual build. There is an [open issue](https://github.com/microsoft/TypeScript/issues/58658) regarding this unexpected behavior. You can use the `--modules` option to have the [differences between ES modules and CommonJS](https://nodejs.org/api/esm.html#differences-between-es-modules-and-commonjs) transformed by `duel` prior to running compilation with `tsc` so that there are no compilation or runtime errors.
75
75
 
76
+ `duel` infers the primary vs dual build orientation from your `package.json` `type`:
77
+
78
+ - `"type": "module"` → primary ESM, dual CJS
79
+ - `"type": "commonjs"` → primary CJS, dual ESM
80
+
81
+ The `--dirs` flag nests outputs under `outDir/esm` and `outDir/cjs` accordingly.
82
+
76
83
  Note, there is a slight performance penalty since your project needs to be copied first to run the transforms before compiling with `tsc`.
77
84
 
78
85
  ```json
@@ -91,6 +98,10 @@ The available options are limited, because you should define most of them inside
91
98
  - `--pkg-dir, -k` The directory to start looking for a package.json file. Defaults to `--project` dir.
92
99
  - `--modules, -m` Transform module globals for dual build target. Defaults to false.
93
100
  - `--dirs, -d` Outputs both builds to directories inside of `outDir`. Defaults to `false`.
101
+ - `--exports, -e` Generate `package.json` `exports` from build output. Values: `wildcard` | `dir` | `name`.
102
+
103
+ > [!NOTE]
104
+ > Exports keys are extensionless by design; the target `import`/`require`/`types` entries keep explicit file extensions so Node resolution remains deterministic.
94
105
 
95
106
  You can run `duel --help` to get the same info. Below is the output of that:
96
107
 
@@ -124,3 +135,5 @@ Fortunately, Node.js has added `--experimental-require-module` so that you can [
124
135
  ## Documentation
125
136
 
126
137
  - [docs/faq.md](docs/faq.md)
138
+ - [docs/exports.md](docs/exports.md)
139
+ - [docs/migrate-v2-v3.md](docs/migrate-v2-v3.md)
package/dist/cjs/duel.cjs CHANGED
@@ -10,19 +10,181 @@ const node_crypto_1 = require("node:crypto");
10
10
  const node_perf_hooks_1 = require("node:perf_hooks");
11
11
  const glob_1 = require("glob");
12
12
  const find_up_1 = require("find-up");
13
- const specifier_1 = require("@knighted/specifier");
14
13
  const module_1 = require("@knighted/module");
15
14
  const init_js_1 = require("./init.cjs");
16
15
  const util_js_1 = require("./util.cjs");
16
+ const stripKnownExt = path => {
17
+ return path.replace(/(\.d\.(?:ts|mts|cts)|\.(?:mjs|cjs|js))$/, '');
18
+ };
19
+ const ensureDotSlash = path => {
20
+ return path.startsWith('./') ? path : `./${path}`;
21
+ };
22
+ const getSubpath = (mode, relFromRoot) => {
23
+ const parsed = (0, node_path_1.parse)(relFromRoot);
24
+ const segments = parsed.dir.split('/').filter(Boolean);
25
+ if (mode === 'name') {
26
+ return parsed.name ? `./${parsed.name}` : null;
27
+ }
28
+ if (mode === 'dir') {
29
+ const last = segments.at(-1);
30
+ return last ? `./${last}` : null;
31
+ }
32
+ if (mode === 'wildcard') {
33
+ const first = segments[0];
34
+ return first ? `./${first}/*` : null;
35
+ }
36
+ return null;
37
+ };
17
38
  const handleErrorAndExit = message => {
18
39
  const exitCode = Number(message);
19
40
  (0, util_js_1.logError)('Compilation errors found.');
20
41
  process.exit(exitCode);
21
42
  };
43
+ const generateExports = async (options) => {
44
+ const { mode, pkg, pkgDir, esmRoot, cjsRoot, mainDefaultKind, mainPath } = options;
45
+ const toPosix = path => path.replace(/\\/g, '/');
46
+ const esmRootPosix = toPosix(esmRoot);
47
+ const cjsRootPosix = toPosix(cjsRoot);
48
+ const esmIgnore = ['node_modules/**'];
49
+ const cjsIgnore = ['node_modules/**'];
50
+ const baseMap = new Map();
51
+ const subpathMap = new Map();
52
+ const baseToSubpath = new Map();
53
+ if (cjsRootPosix.startsWith(`${esmRootPosix}/`)) {
54
+ esmIgnore.push(`${cjsRootPosix}/**`);
55
+ }
56
+ if (esmRootPosix.startsWith(`${cjsRootPosix}/`)) {
57
+ cjsIgnore.push(`${esmRootPosix}/**`);
58
+ }
59
+ const recordPath = (kind, filePath, root) => {
60
+ const relPkg = toPosix((0, node_path_1.relative)(pkgDir, filePath));
61
+ const relFromRoot = toPosix((0, node_path_1.relative)(root, filePath));
62
+ const withDot = ensureDotSlash(relPkg);
63
+ const baseKey = stripKnownExt(relPkg);
64
+ const baseEntry = baseMap.get(baseKey) ?? {};
65
+ baseEntry[kind] = withDot;
66
+ baseMap.set(baseKey, baseEntry);
67
+ const subpath = getSubpath(mode, relFromRoot);
68
+ if (kind === 'types') {
69
+ const mappedSubpath = baseToSubpath.get(baseKey);
70
+ if (mappedSubpath) {
71
+ const subEntry = subpathMap.get(mappedSubpath) ?? {};
72
+ subEntry.types = withDot;
73
+ subpathMap.set(mappedSubpath, subEntry);
74
+ }
75
+ return;
76
+ }
77
+ if (subpath && subpath !== '.') {
78
+ const subEntry = subpathMap.get(subpath) ?? {};
79
+ subEntry[kind] = withDot;
80
+ subpathMap.set(subpath, subEntry);
81
+ baseToSubpath.set(baseKey, subpath);
82
+ }
83
+ };
84
+ const esmFiles = await (0, glob_1.glob)(`${esmRootPosix}/**/*.{js,mjs,d.ts,d.mts}`, {
85
+ ignore: esmIgnore,
86
+ });
87
+ for (const file of esmFiles) {
88
+ if (/\.d\.(ts|mts)$/.test(file)) {
89
+ recordPath('types', file, esmRoot);
90
+ }
91
+ else {
92
+ recordPath('import', file, esmRoot);
93
+ }
94
+ }
95
+ const cjsFiles = await (0, glob_1.glob)(`${cjsRootPosix}/**/*.{js,cjs,d.ts,d.cts}`, {
96
+ ignore: cjsIgnore,
97
+ });
98
+ for (const file of cjsFiles) {
99
+ if (/\.d\.(ts|cts)$/.test(file)) {
100
+ recordPath('types', file, cjsRoot);
101
+ }
102
+ else {
103
+ recordPath('require', file, cjsRoot);
104
+ }
105
+ }
106
+ const exportsMap = {};
107
+ const mainBase = mainPath ? stripKnownExt(mainPath.replace(/^\.\//, '')) : null;
108
+ const mainEntry = mainBase ? (baseMap.get(mainBase) ?? {}) : {};
109
+ if (mainPath) {
110
+ const rootEntry = {};
111
+ if (mainEntry.types) {
112
+ rootEntry.types = mainEntry.types;
113
+ }
114
+ if (mainDefaultKind === 'import') {
115
+ rootEntry.import = mainEntry.import ?? ensureDotSlash(mainPath);
116
+ if (mainEntry.require) {
117
+ rootEntry.require = mainEntry.require;
118
+ }
119
+ }
120
+ else {
121
+ rootEntry.require = mainEntry.require ?? ensureDotSlash(mainPath);
122
+ if (mainEntry.import) {
123
+ rootEntry.import = mainEntry.import;
124
+ }
125
+ }
126
+ rootEntry.default = ensureDotSlash(mainPath);
127
+ exportsMap['.'] = rootEntry;
128
+ }
129
+ const defaultKind = mainDefaultKind ?? 'import';
130
+ for (const [subpath, entry] of subpathMap.entries()) {
131
+ const out = {};
132
+ if (entry.types) {
133
+ out.types = entry.types;
134
+ }
135
+ if (entry.import) {
136
+ out.import = entry.import;
137
+ }
138
+ if (entry.require) {
139
+ out.require = entry.require;
140
+ }
141
+ const def = defaultKind === 'import'
142
+ ? (entry.import ?? entry.require)
143
+ : (entry.require ?? entry.import);
144
+ if (def) {
145
+ out.default = def;
146
+ }
147
+ if (Object.keys(out).length) {
148
+ exportsMap[subpath] = out;
149
+ }
150
+ }
151
+ if (!exportsMap['.'] && baseMap.size) {
152
+ const [subpath, entry] = subpathMap.entries().next().value ?? [];
153
+ if (entry) {
154
+ const out = {};
155
+ if (entry.types) {
156
+ out.types = entry.types;
157
+ }
158
+ if (entry.import) {
159
+ out.import = entry.import;
160
+ }
161
+ if (entry.require) {
162
+ out.require = entry.require;
163
+ }
164
+ const def = defaultKind === 'import'
165
+ ? (entry.import ?? entry.require)
166
+ : (entry.require ?? entry.import);
167
+ if (def) {
168
+ out.default = def;
169
+ }
170
+ if (Object.keys(out).length) {
171
+ exportsMap['.'] = out;
172
+ exportsMap[subpath] ??= out;
173
+ }
174
+ }
175
+ }
176
+ if (Object.keys(exportsMap).length) {
177
+ const pkgJson = {
178
+ ...pkg.packageJson,
179
+ exports: exportsMap,
180
+ };
181
+ await (0, promises_1.writeFile)(pkg.path, `${JSON.stringify(pkgJson, null, 2)}\n`);
182
+ }
183
+ };
22
184
  const duel = async (args) => {
23
185
  const ctx = await (0, init_js_1.init)(args);
24
186
  if (ctx) {
25
- const { projectDir, tsconfig, configPath, modules, dirs, pkg } = ctx;
187
+ const { projectDir, tsconfig, configPath, modules, dirs, pkg, exports: exportsOpt, } = ctx;
26
188
  const tsc = await (0, find_up_1.findUp)(async (dir) => {
27
189
  const tscBin = (0, node_path_1.join)(dir, 'node_modules', '.bin', 'tsc');
28
190
  try {
@@ -46,11 +208,17 @@ const duel = async (args) => {
46
208
  });
47
209
  };
48
210
  const pkgDir = (0, node_path_1.dirname)(pkg.path);
211
+ const mainPath = pkg.packageJson.main;
212
+ const mainDefaultKind = mainPath?.endsWith('.cjs') ? 'require' : 'import';
49
213
  const outDir = tsconfig.compilerOptions?.outDir ?? 'dist';
50
214
  const absoluteOutDir = (0, node_path_1.resolve)(projectDir, outDir);
51
215
  const originalType = pkg.packageJson.type ?? 'commonjs';
52
216
  const isCjsBuild = originalType !== 'commonjs';
53
- const targetExt = isCjsBuild ? '.cjs' : '.mjs';
217
+ const primaryOutDir = dirs
218
+ ? isCjsBuild
219
+ ? (0, node_path_1.join)(absoluteOutDir, 'esm')
220
+ : (0, node_path_1.join)(absoluteOutDir, 'cjs')
221
+ : absoluteOutDir;
54
222
  const hex = (0, node_crypto_1.randomBytes)(4).toString('hex');
55
223
  const getOverrideTsConfig = () => {
56
224
  return {
@@ -63,29 +231,39 @@ const duel = async (args) => {
63
231
  };
64
232
  };
65
233
  const runPrimaryBuild = () => {
66
- return runBuild(configPath, dirs
67
- ? isCjsBuild
68
- ? (0, node_path_1.join)(absoluteOutDir, 'esm')
69
- : (0, node_path_1.join)(absoluteOutDir, 'cjs')
70
- : absoluteOutDir);
234
+ return runBuild(configPath, primaryOutDir);
71
235
  };
72
- const updateSpecifiersAndFileExtensions = async (filenames) => {
236
+ const updateSpecifiersAndFileExtensions = async (filenames, target, ext) => {
73
237
  for (const filename of filenames) {
74
238
  const dts = /(\.d\.ts)$/;
75
- const outFilename = dts.test(filename)
76
- ? filename.replace(dts, isCjsBuild ? '.d.cts' : '.d.mts')
77
- : filename.replace(/\.js$/, targetExt);
78
- const update = await specifier_1.specifier.update(filename, ({ value }) => {
79
- // Collapse any BinaryExpression or NewExpression to test for a relative specifier
239
+ const isDts = dts.test(filename);
240
+ const outFilename = isDts
241
+ ? filename.replace(dts, target === 'commonjs' ? '.d.cts' : '.d.mts')
242
+ : filename.replace(/\.js$/, ext);
243
+ if (isDts) {
244
+ const source = await (0, promises_1.readFile)(filename, 'utf8');
245
+ const rewritten = source.replace(/(?<=['"])(\.\.?(?:\/[\w.-]+)*)\.js(?=['"])/g, `$1${ext}`);
246
+ await (0, promises_1.writeFile)(outFilename, rewritten);
247
+ if (outFilename !== filename) {
248
+ await (0, promises_1.rm)(filename, { force: true });
249
+ }
250
+ continue;
251
+ }
252
+ const rewriteSpecifier = (value = '') => {
80
253
  const collapsed = value.replace(/['"`+)\s]|new String\(/g, '');
81
- const relative = /^(?:\.|\.\.)\//;
82
- if (relative.test(collapsed)) {
83
- // $2 is for any closing quotation/parens around BE or NE
84
- return value.replace(/(.+)\.js([)'"`]*)?$/, `$1${targetExt}$2`);
254
+ if (/^(?:\.|\.\.)\//.test(collapsed)) {
255
+ return value.replace(/(.+)\.js([)"'`]*)?$/, `$1${ext}$2`);
85
256
  }
86
- });
87
- await (0, promises_1.writeFile)(outFilename, update);
88
- await (0, promises_1.rm)(filename, { force: true });
257
+ };
258
+ const writeOptions = {
259
+ target,
260
+ rewriteSpecifier,
261
+ ...(outFilename === filename ? { inPlace: true } : { out: outFilename }),
262
+ };
263
+ await (0, module_1.transform)(filename, writeOptions);
264
+ if (outFilename !== filename) {
265
+ await (0, promises_1.rm)(filename, { force: true });
266
+ }
89
267
  }
90
268
  };
91
269
  const logSuccess = start => {
@@ -112,7 +290,11 @@ const duel = async (args) => {
112
290
  const compileFiles = (0, util_js_1.getCompileFiles)(tsc, projectDir);
113
291
  dualConfigPath = (0, node_path_1.join)(subDir, `tsconfig.${hex}.json`);
114
292
  await (0, promises_1.mkdir)(subDir);
115
- await Promise.all(compileFiles.map(file => (0, promises_1.cp)(file, (0, node_path_1.join)(subDir, (0, node_path_1.relative)(projectDir, file).replace(/^(\.\.\/)*/, '')))));
293
+ await Promise.all(compileFiles.map(async (file) => {
294
+ const dest = (0, node_path_1.join)(subDir, (0, node_path_1.relative)(projectDir, file).replace(/^(\.\.\/)+/, ''));
295
+ await (0, promises_1.mkdir)((0, node_path_1.dirname)(dest), { recursive: true });
296
+ await (0, promises_1.cp)(file, dest);
297
+ }));
116
298
  /**
117
299
  * Transform ambiguous modules for the target dual build.
118
300
  * @see https://github.com/microsoft/TypeScript/issues/58658
@@ -129,7 +311,11 @@ const duel = async (args) => {
129
311
  *
130
312
  * @see https://github.com/microsoft/TypeScript/issues/58658
131
313
  */
132
- await (0, module_1.transform)(file, { out: file, type: isCjsBuild ? 'commonjs' : 'module' });
314
+ await (0, module_1.transform)(file, {
315
+ out: file,
316
+ target: isCjsBuild ? 'commonjs' : 'module',
317
+ transformSyntax: false,
318
+ });
133
319
  }
134
320
  }
135
321
  /**
@@ -161,10 +347,29 @@ const duel = async (args) => {
161
347
  }
162
348
  }
163
349
  if (success) {
350
+ const dualTarget = isCjsBuild ? 'commonjs' : 'module';
351
+ const dualTargetExt = isCjsBuild ? '.cjs' : dirs ? '.js' : '.mjs';
164
352
  const filenames = await (0, glob_1.glob)(`${absoluteDualOutDir.replace(/\\/g, '/')}/**/*{.js,.d.ts}`, {
165
353
  ignore: 'node_modules/**',
166
354
  });
167
- await updateSpecifiersAndFileExtensions(filenames);
355
+ await updateSpecifiersAndFileExtensions(filenames, dualTarget, dualTargetExt);
356
+ if (dirs && originalType === 'commonjs') {
357
+ const primaryFiles = await (0, glob_1.glob)(`${primaryOutDir.replace(/\\/g, '/')}/**/*{.js,.d.ts}`, { ignore: 'node_modules/**' });
358
+ await updateSpecifiersAndFileExtensions(primaryFiles, 'commonjs', '.cjs');
359
+ }
360
+ if (exportsOpt) {
361
+ const esmRoot = isCjsBuild ? primaryOutDir : absoluteDualOutDir;
362
+ const cjsRoot = isCjsBuild ? absoluteDualOutDir : primaryOutDir;
363
+ await generateExports({
364
+ mode: exportsOpt,
365
+ pkg,
366
+ pkgDir,
367
+ esmRoot,
368
+ cjsRoot,
369
+ mainDefaultKind,
370
+ mainPath,
371
+ });
372
+ }
168
373
  logSuccess(startTime);
169
374
  }
170
375
  }
@@ -173,7 +378,7 @@ const duel = async (args) => {
173
378
  exports.duel = duel;
174
379
  (async () => {
175
380
  const realFileUrlArgv1 = await (0, util_js_1.getRealPathAsFileUrl)(node_process_1.argv[1] ?? '');
176
- if (require("node:url").pathToFileURL(__filename).toString() === realFileUrlArgv1) {
381
+ if (require("node:url").pathToFileURL(__filename).href === realFileUrlArgv1) {
177
382
  await duel();
178
383
  }
179
384
  })();
package/dist/cjs/init.cjs CHANGED
@@ -37,6 +37,10 @@ const init = async (args) => {
37
37
  short: 'd',
38
38
  default: false,
39
39
  },
40
+ exports: {
41
+ type: 'string',
42
+ short: 'e',
43
+ },
40
44
  help: {
41
45
  type: 'boolean',
42
46
  short: 'h',
@@ -57,10 +61,11 @@ const init = async (args) => {
57
61
  (0, util_js_1.log)('--pkg-dir, -k [path] \t The directory to start looking for a package.json file. Defaults to --project directory.');
58
62
  (0, util_js_1.log)('--modules, -m \t\t Transform module globals for dual build target. Defaults to false.');
59
63
  (0, util_js_1.log)('--dirs, -d \t\t Output both builds to directories inside of outDir. [esm, cjs].');
64
+ (0, util_js_1.log)('--exports, -e \t Generate package.json exports. Values: wildcard | dir | name.');
60
65
  (0, util_js_1.log)('--help, -h \t\t Print this message.');
61
66
  }
62
67
  else {
63
- const { project, 'target-extension': targetExt, 'pkg-dir': pkgDir, modules, dirs, } = parsed;
68
+ const { project, 'target-extension': targetExt, 'pkg-dir': pkgDir, modules, dirs, exports: exportsOpt, } = parsed;
64
69
  let configPath = (0, node_path_1.resolve)(project);
65
70
  let stats = null;
66
71
  let pkg = null;
@@ -96,10 +101,15 @@ const init = async (args) => {
96
101
  if (!tsconfig.compilerOptions?.outDir) {
97
102
  (0, util_js_1.log)('No outDir defined in tsconfig.json. Build output will be in "dist".');
98
103
  }
104
+ if (exportsOpt && !['wildcard', 'dir', 'name'].includes(exportsOpt)) {
105
+ (0, util_js_1.logError)('--exports expects one of: wildcard | dir | name');
106
+ return false;
107
+ }
99
108
  return {
100
109
  pkg,
101
110
  dirs,
102
111
  modules,
112
+ exports: exportsOpt,
103
113
  tsconfig,
104
114
  projectDir,
105
115
  configPath,
@@ -2,6 +2,7 @@ export function init(args: any): Promise<false | {
2
2
  pkg: import("read-package-up", { with: { "resolution-mode": "import" } }).NormalizedReadResult;
3
3
  dirs: boolean;
4
4
  modules: boolean;
5
+ exports: string | undefined;
5
6
  tsconfig: {
6
7
  compilerOptions?: import("get-tsconfig").TsConfigJson.CompilerOptions | undefined;
7
8
  watchOptions?: import("get-tsconfig").TsConfigJson.WatchOptions | undefined;
package/dist/esm/duel.js CHANGED
@@ -1,25 +1,187 @@
1
1
  #!/usr/bin/env node
2
2
  import { argv, platform } from 'node:process';
3
- import { join, dirname, resolve, relative } from 'node:path';
3
+ import { join, dirname, resolve, relative, parse as parsePath } from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
- import { writeFile, rm, rename, mkdir, cp, access } from 'node:fs/promises';
5
+ import { writeFile, rm, rename, mkdir, cp, access, readFile } from 'node:fs/promises';
6
6
  import { randomBytes } from 'node:crypto';
7
7
  import { performance } from 'node:perf_hooks';
8
8
  import { glob } from 'glob';
9
9
  import { findUp } from 'find-up';
10
- import { specifier } from '@knighted/specifier';
11
10
  import { transform } from '@knighted/module';
12
11
  import { init } from './init.js';
13
12
  import { getRealPathAsFileUrl, getCompileFiles, logError, log } from './util.js';
13
+ const stripKnownExt = path => {
14
+ return path.replace(/(\.d\.(?:ts|mts|cts)|\.(?:mjs|cjs|js))$/, '');
15
+ };
16
+ const ensureDotSlash = path => {
17
+ return path.startsWith('./') ? path : `./${path}`;
18
+ };
19
+ const getSubpath = (mode, relFromRoot) => {
20
+ const parsed = parsePath(relFromRoot);
21
+ const segments = parsed.dir.split('/').filter(Boolean);
22
+ if (mode === 'name') {
23
+ return parsed.name ? `./${parsed.name}` : null;
24
+ }
25
+ if (mode === 'dir') {
26
+ const last = segments.at(-1);
27
+ return last ? `./${last}` : null;
28
+ }
29
+ if (mode === 'wildcard') {
30
+ const first = segments[0];
31
+ return first ? `./${first}/*` : null;
32
+ }
33
+ return null;
34
+ };
14
35
  const handleErrorAndExit = message => {
15
36
  const exitCode = Number(message);
16
37
  logError('Compilation errors found.');
17
38
  process.exit(exitCode);
18
39
  };
40
+ const generateExports = async (options) => {
41
+ const { mode, pkg, pkgDir, esmRoot, cjsRoot, mainDefaultKind, mainPath } = options;
42
+ const toPosix = path => path.replace(/\\/g, '/');
43
+ const esmRootPosix = toPosix(esmRoot);
44
+ const cjsRootPosix = toPosix(cjsRoot);
45
+ const esmIgnore = ['node_modules/**'];
46
+ const cjsIgnore = ['node_modules/**'];
47
+ const baseMap = new Map();
48
+ const subpathMap = new Map();
49
+ const baseToSubpath = new Map();
50
+ if (cjsRootPosix.startsWith(`${esmRootPosix}/`)) {
51
+ esmIgnore.push(`${cjsRootPosix}/**`);
52
+ }
53
+ if (esmRootPosix.startsWith(`${cjsRootPosix}/`)) {
54
+ cjsIgnore.push(`${esmRootPosix}/**`);
55
+ }
56
+ const recordPath = (kind, filePath, root) => {
57
+ const relPkg = toPosix(relative(pkgDir, filePath));
58
+ const relFromRoot = toPosix(relative(root, filePath));
59
+ const withDot = ensureDotSlash(relPkg);
60
+ const baseKey = stripKnownExt(relPkg);
61
+ const baseEntry = baseMap.get(baseKey) ?? {};
62
+ baseEntry[kind] = withDot;
63
+ baseMap.set(baseKey, baseEntry);
64
+ const subpath = getSubpath(mode, relFromRoot);
65
+ if (kind === 'types') {
66
+ const mappedSubpath = baseToSubpath.get(baseKey);
67
+ if (mappedSubpath) {
68
+ const subEntry = subpathMap.get(mappedSubpath) ?? {};
69
+ subEntry.types = withDot;
70
+ subpathMap.set(mappedSubpath, subEntry);
71
+ }
72
+ return;
73
+ }
74
+ if (subpath && subpath !== '.') {
75
+ const subEntry = subpathMap.get(subpath) ?? {};
76
+ subEntry[kind] = withDot;
77
+ subpathMap.set(subpath, subEntry);
78
+ baseToSubpath.set(baseKey, subpath);
79
+ }
80
+ };
81
+ const esmFiles = await glob(`${esmRootPosix}/**/*.{js,mjs,d.ts,d.mts}`, {
82
+ ignore: esmIgnore,
83
+ });
84
+ for (const file of esmFiles) {
85
+ if (/\.d\.(ts|mts)$/.test(file)) {
86
+ recordPath('types', file, esmRoot);
87
+ }
88
+ else {
89
+ recordPath('import', file, esmRoot);
90
+ }
91
+ }
92
+ const cjsFiles = await glob(`${cjsRootPosix}/**/*.{js,cjs,d.ts,d.cts}`, {
93
+ ignore: cjsIgnore,
94
+ });
95
+ for (const file of cjsFiles) {
96
+ if (/\.d\.(ts|cts)$/.test(file)) {
97
+ recordPath('types', file, cjsRoot);
98
+ }
99
+ else {
100
+ recordPath('require', file, cjsRoot);
101
+ }
102
+ }
103
+ const exportsMap = {};
104
+ const mainBase = mainPath ? stripKnownExt(mainPath.replace(/^\.\//, '')) : null;
105
+ const mainEntry = mainBase ? (baseMap.get(mainBase) ?? {}) : {};
106
+ if (mainPath) {
107
+ const rootEntry = {};
108
+ if (mainEntry.types) {
109
+ rootEntry.types = mainEntry.types;
110
+ }
111
+ if (mainDefaultKind === 'import') {
112
+ rootEntry.import = mainEntry.import ?? ensureDotSlash(mainPath);
113
+ if (mainEntry.require) {
114
+ rootEntry.require = mainEntry.require;
115
+ }
116
+ }
117
+ else {
118
+ rootEntry.require = mainEntry.require ?? ensureDotSlash(mainPath);
119
+ if (mainEntry.import) {
120
+ rootEntry.import = mainEntry.import;
121
+ }
122
+ }
123
+ rootEntry.default = ensureDotSlash(mainPath);
124
+ exportsMap['.'] = rootEntry;
125
+ }
126
+ const defaultKind = mainDefaultKind ?? 'import';
127
+ for (const [subpath, entry] of subpathMap.entries()) {
128
+ const out = {};
129
+ if (entry.types) {
130
+ out.types = entry.types;
131
+ }
132
+ if (entry.import) {
133
+ out.import = entry.import;
134
+ }
135
+ if (entry.require) {
136
+ out.require = entry.require;
137
+ }
138
+ const def = defaultKind === 'import'
139
+ ? (entry.import ?? entry.require)
140
+ : (entry.require ?? entry.import);
141
+ if (def) {
142
+ out.default = def;
143
+ }
144
+ if (Object.keys(out).length) {
145
+ exportsMap[subpath] = out;
146
+ }
147
+ }
148
+ if (!exportsMap['.'] && baseMap.size) {
149
+ const [subpath, entry] = subpathMap.entries().next().value ?? [];
150
+ if (entry) {
151
+ const out = {};
152
+ if (entry.types) {
153
+ out.types = entry.types;
154
+ }
155
+ if (entry.import) {
156
+ out.import = entry.import;
157
+ }
158
+ if (entry.require) {
159
+ out.require = entry.require;
160
+ }
161
+ const def = defaultKind === 'import'
162
+ ? (entry.import ?? entry.require)
163
+ : (entry.require ?? entry.import);
164
+ if (def) {
165
+ out.default = def;
166
+ }
167
+ if (Object.keys(out).length) {
168
+ exportsMap['.'] = out;
169
+ exportsMap[subpath] ??= out;
170
+ }
171
+ }
172
+ }
173
+ if (Object.keys(exportsMap).length) {
174
+ const pkgJson = {
175
+ ...pkg.packageJson,
176
+ exports: exportsMap,
177
+ };
178
+ await writeFile(pkg.path, `${JSON.stringify(pkgJson, null, 2)}\n`);
179
+ }
180
+ };
19
181
  const duel = async (args) => {
20
182
  const ctx = await init(args);
21
183
  if (ctx) {
22
- const { projectDir, tsconfig, configPath, modules, dirs, pkg } = ctx;
184
+ const { projectDir, tsconfig, configPath, modules, dirs, pkg, exports: exportsOpt, } = ctx;
23
185
  const tsc = await findUp(async (dir) => {
24
186
  const tscBin = join(dir, 'node_modules', '.bin', 'tsc');
25
187
  try {
@@ -43,11 +205,17 @@ const duel = async (args) => {
43
205
  });
44
206
  };
45
207
  const pkgDir = dirname(pkg.path);
208
+ const mainPath = pkg.packageJson.main;
209
+ const mainDefaultKind = mainPath?.endsWith('.cjs') ? 'require' : 'import';
46
210
  const outDir = tsconfig.compilerOptions?.outDir ?? 'dist';
47
211
  const absoluteOutDir = resolve(projectDir, outDir);
48
212
  const originalType = pkg.packageJson.type ?? 'commonjs';
49
213
  const isCjsBuild = originalType !== 'commonjs';
50
- const targetExt = isCjsBuild ? '.cjs' : '.mjs';
214
+ const primaryOutDir = dirs
215
+ ? isCjsBuild
216
+ ? join(absoluteOutDir, 'esm')
217
+ : join(absoluteOutDir, 'cjs')
218
+ : absoluteOutDir;
51
219
  const hex = randomBytes(4).toString('hex');
52
220
  const getOverrideTsConfig = () => {
53
221
  return {
@@ -60,29 +228,39 @@ const duel = async (args) => {
60
228
  };
61
229
  };
62
230
  const runPrimaryBuild = () => {
63
- return runBuild(configPath, dirs
64
- ? isCjsBuild
65
- ? join(absoluteOutDir, 'esm')
66
- : join(absoluteOutDir, 'cjs')
67
- : absoluteOutDir);
231
+ return runBuild(configPath, primaryOutDir);
68
232
  };
69
- const updateSpecifiersAndFileExtensions = async (filenames) => {
233
+ const updateSpecifiersAndFileExtensions = async (filenames, target, ext) => {
70
234
  for (const filename of filenames) {
71
235
  const dts = /(\.d\.ts)$/;
72
- const outFilename = dts.test(filename)
73
- ? filename.replace(dts, isCjsBuild ? '.d.cts' : '.d.mts')
74
- : filename.replace(/\.js$/, targetExt);
75
- const update = await specifier.update(filename, ({ value }) => {
76
- // Collapse any BinaryExpression or NewExpression to test for a relative specifier
236
+ const isDts = dts.test(filename);
237
+ const outFilename = isDts
238
+ ? filename.replace(dts, target === 'commonjs' ? '.d.cts' : '.d.mts')
239
+ : filename.replace(/\.js$/, ext);
240
+ if (isDts) {
241
+ const source = await readFile(filename, 'utf8');
242
+ const rewritten = source.replace(/(?<=['"])(\.\.?(?:\/[\w.-]+)*)\.js(?=['"])/g, `$1${ext}`);
243
+ await writeFile(outFilename, rewritten);
244
+ if (outFilename !== filename) {
245
+ await rm(filename, { force: true });
246
+ }
247
+ continue;
248
+ }
249
+ const rewriteSpecifier = (value = '') => {
77
250
  const collapsed = value.replace(/['"`+)\s]|new String\(/g, '');
78
- const relative = /^(?:\.|\.\.)\//;
79
- if (relative.test(collapsed)) {
80
- // $2 is for any closing quotation/parens around BE or NE
81
- return value.replace(/(.+)\.js([)'"`]*)?$/, `$1${targetExt}$2`);
251
+ if (/^(?:\.|\.\.)\//.test(collapsed)) {
252
+ return value.replace(/(.+)\.js([)"'`]*)?$/, `$1${ext}$2`);
82
253
  }
83
- });
84
- await writeFile(outFilename, update);
85
- await rm(filename, { force: true });
254
+ };
255
+ const writeOptions = {
256
+ target,
257
+ rewriteSpecifier,
258
+ ...(outFilename === filename ? { inPlace: true } : { out: outFilename }),
259
+ };
260
+ await transform(filename, writeOptions);
261
+ if (outFilename !== filename) {
262
+ await rm(filename, { force: true });
263
+ }
86
264
  }
87
265
  };
88
266
  const logSuccess = start => {
@@ -109,7 +287,11 @@ const duel = async (args) => {
109
287
  const compileFiles = getCompileFiles(tsc, projectDir);
110
288
  dualConfigPath = join(subDir, `tsconfig.${hex}.json`);
111
289
  await mkdir(subDir);
112
- await Promise.all(compileFiles.map(file => cp(file, join(subDir, relative(projectDir, file).replace(/^(\.\.\/)*/, '')))));
290
+ await Promise.all(compileFiles.map(async (file) => {
291
+ const dest = join(subDir, relative(projectDir, file).replace(/^(\.\.\/)+/, ''));
292
+ await mkdir(dirname(dest), { recursive: true });
293
+ await cp(file, dest);
294
+ }));
113
295
  /**
114
296
  * Transform ambiguous modules for the target dual build.
115
297
  * @see https://github.com/microsoft/TypeScript/issues/58658
@@ -126,7 +308,11 @@ const duel = async (args) => {
126
308
  *
127
309
  * @see https://github.com/microsoft/TypeScript/issues/58658
128
310
  */
129
- await transform(file, { out: file, type: isCjsBuild ? 'commonjs' : 'module' });
311
+ await transform(file, {
312
+ out: file,
313
+ target: isCjsBuild ? 'commonjs' : 'module',
314
+ transformSyntax: false,
315
+ });
130
316
  }
131
317
  }
132
318
  /**
@@ -158,10 +344,29 @@ const duel = async (args) => {
158
344
  }
159
345
  }
160
346
  if (success) {
347
+ const dualTarget = isCjsBuild ? 'commonjs' : 'module';
348
+ const dualTargetExt = isCjsBuild ? '.cjs' : dirs ? '.js' : '.mjs';
161
349
  const filenames = await glob(`${absoluteDualOutDir.replace(/\\/g, '/')}/**/*{.js,.d.ts}`, {
162
350
  ignore: 'node_modules/**',
163
351
  });
164
- await updateSpecifiersAndFileExtensions(filenames);
352
+ await updateSpecifiersAndFileExtensions(filenames, dualTarget, dualTargetExt);
353
+ if (dirs && originalType === 'commonjs') {
354
+ const primaryFiles = await glob(`${primaryOutDir.replace(/\\/g, '/')}/**/*{.js,.d.ts}`, { ignore: 'node_modules/**' });
355
+ await updateSpecifiersAndFileExtensions(primaryFiles, 'commonjs', '.cjs');
356
+ }
357
+ if (exportsOpt) {
358
+ const esmRoot = isCjsBuild ? primaryOutDir : absoluteDualOutDir;
359
+ const cjsRoot = isCjsBuild ? absoluteDualOutDir : primaryOutDir;
360
+ await generateExports({
361
+ mode: exportsOpt,
362
+ pkg,
363
+ pkgDir,
364
+ esmRoot,
365
+ cjsRoot,
366
+ mainDefaultKind,
367
+ mainPath,
368
+ });
369
+ }
165
370
  logSuccess(startTime);
166
371
  }
167
372
  }
@@ -2,6 +2,7 @@ export function init(args: any): Promise<false | {
2
2
  pkg: import("read-package-up").NormalizedReadResult;
3
3
  dirs: boolean;
4
4
  modules: boolean;
5
+ exports: string | undefined;
5
6
  tsconfig: {
6
7
  compilerOptions?: import("get-tsconfig").TsConfigJson.CompilerOptions | undefined;
7
8
  watchOptions?: import("get-tsconfig").TsConfigJson.WatchOptions | undefined;
package/dist/esm/init.js CHANGED
@@ -34,6 +34,10 @@ const init = async (args) => {
34
34
  short: 'd',
35
35
  default: false,
36
36
  },
37
+ exports: {
38
+ type: 'string',
39
+ short: 'e',
40
+ },
37
41
  help: {
38
42
  type: 'boolean',
39
43
  short: 'h',
@@ -54,10 +58,11 @@ const init = async (args) => {
54
58
  log('--pkg-dir, -k [path] \t The directory to start looking for a package.json file. Defaults to --project directory.');
55
59
  log('--modules, -m \t\t Transform module globals for dual build target. Defaults to false.');
56
60
  log('--dirs, -d \t\t Output both builds to directories inside of outDir. [esm, cjs].');
61
+ log('--exports, -e \t Generate package.json exports. Values: wildcard | dir | name.');
57
62
  log('--help, -h \t\t Print this message.');
58
63
  }
59
64
  else {
60
- const { project, 'target-extension': targetExt, 'pkg-dir': pkgDir, modules, dirs, } = parsed;
65
+ const { project, 'target-extension': targetExt, 'pkg-dir': pkgDir, modules, dirs, exports: exportsOpt, } = parsed;
61
66
  let configPath = resolve(project);
62
67
  let stats = null;
63
68
  let pkg = null;
@@ -93,10 +98,15 @@ const init = async (args) => {
93
98
  if (!tsconfig.compilerOptions?.outDir) {
94
99
  log('No outDir defined in tsconfig.json. Build output will be in "dist".');
95
100
  }
101
+ if (exportsOpt && !['wildcard', 'dir', 'name'].includes(exportsOpt)) {
102
+ logError('--exports expects one of: wildcard | dir | name');
103
+ return false;
104
+ }
96
105
  return {
97
106
  pkg,
98
107
  dirs,
99
108
  modules,
109
+ exports: exportsOpt,
100
110
  tsconfig,
101
111
  projectDir,
102
112
  configPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knighted/duel",
3
- "version": "2.1.7",
3
+ "version": "3.1.0",
4
4
  "description": "TypeScript dual packages.",
5
5
  "type": "module",
6
6
  "main": "dist/esm/duel.js",
@@ -16,7 +16,7 @@
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "engines": {
19
- "node": ">=20.11.0"
19
+ "node": ">=22.21.1 <23 || >=24 <25"
20
20
  },
21
21
  "engineStrict": true,
22
22
  "scripts": {
@@ -67,6 +67,7 @@
67
67
  },
68
68
  "devDependencies": {
69
69
  "@eslint/js": "^9.39.1",
70
+ "@knighted/module": "^1.0.0-rc.2",
70
71
  "@tsconfig/recommended": "^1.0.10",
71
72
  "@types/node": "^24.10.1",
72
73
  "c8": "^10.1.3",
@@ -82,8 +83,6 @@
82
83
  "vite": "^7.2.4"
83
84
  },
84
85
  "dependencies": {
85
- "@knighted/module": "^1.0.0-alpha.10",
86
- "@knighted/specifier": "^2.0.9",
87
86
  "find-up": "^8.0.0",
88
87
  "get-tsconfig": "^4.13.0",
89
88
  "glob": "^13.0.0",