@knighted/module 1.2.0 → 1.3.0-rc.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,6 +16,7 @@ Highlights
16
16
  - Configurable lowering modes: full syntax transforms or globals-only.
17
17
  - Specifier tools: add extensions, add directory indexes, or map with a custom callback.
18
18
  - Output control: write to disk (`out`/`inPlace`) or return the transformed string.
19
+ - CLI: `dub` for batch transforms, dry-run/list/summary, stdin/stdout, and colorized diagnostics. See [docs/cli.md](docs/cli.md).
19
20
 
20
21
  > [!IMPORTANT]
21
22
  > All parsing logic is applied under the assumption the code is in [strict mode](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode) which [modules run under by default](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#other_differences_between_modules_and_classic_scripts).
@@ -143,7 +144,7 @@ type ModuleOptions = {
143
144
  ### Behavior notes (defaults in parentheses)
144
145
 
145
146
  - `target` (`commonjs`): output module system.
146
- - `transformSyntax` (true): enable/disable the ESM↔CJS lowering pass; set to `'globals-only'` to rewrite module globals (`import.meta.*`, `__dirname`, `__filename`, `require.main` shims) while leaving import/export syntax untouched. In `'globals-only'`, no helpers are injected (e.g., `__requireResolve`), `require.resolve` rewrites to `import.meta.resolve`, and `idiomaticExports` is skipped. See [globals-only](#globals-only-scope).
147
+ - `transformSyntax` (`true`): enable/disable the ESM↔CJS lowering pass; set to `'globals-only'` to rewrite module globals (`import.meta.*`, `__dirname`, `__filename`, `require.main` shims) while leaving import/export syntax untouched. In `'globals-only'`, no helpers are injected (e.g., `__requireResolve`), `require.resolve` rewrites to `import.meta.resolve`, and `idiomaticExports` is skipped. See [globals-only](docs/globals-only.md).
147
148
  - `liveBindings` (`strict`): getter-based live bindings, or snapshot (`loose`/`off`).
148
149
  - `appendJsExtension` (`relative-only` when targeting ESM): append `.js` to relative specifiers; never touches bare specifiers.
149
150
  - `appendDirectoryIndex` (`index.js`): when a relative specifier ends with a slash, append this index filename (set `false` to disable).
@@ -159,7 +160,7 @@ type ModuleOptions = {
159
160
  - `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`.
160
161
  - `cjsDefault` (`auto`): bundler-style default interop vs direct `module.exports`.
161
162
  - `idiomaticExports` (`safe`): when raising CJS to ESM, attempt to synthesize `export` statements directly when it is safe. `off` always uses the helper bag; `aggressive` currently matches `safe` heuristics.
162
- - `out`/`inPlace`: write the transformed code to a file; otherwise the function returns the transformed string only.
163
+ - `out`/`inPlace`: choose output location. Default returns the transformed string (CLI emits to stdout). `out` writes to the provided path. `inPlace` overwrites the input files on disk and does not return/emit the code.
163
164
  - `cwd` (`process.cwd()`): Base directory used to resolve relative `out` paths.
164
165
 
165
166
  > [!NOTE]
@@ -170,13 +171,6 @@ See [docs/esm-to-cjs.md](docs/esm-to-cjs.md) for deeper notes on live bindings,
170
171
  > [!NOTE]
171
172
  > Known limitations: `with` and unshadowed `eval` are rejected when raising CJS to ESM because the rewrite would be unsound; bare specifiers are not rewritten—only relative specifiers participate in `rewriteSpecifier`.
172
173
 
173
- ### Globals-only scope
174
-
175
- - Rewrites module globals (`import.meta.*`, `__dirname`, `__filename`, `require.main` shims) for the target side.
176
- - Optional specifier rewrites still run (`rewriteSpecifier`, `appendJsExtension`, `appendDirectoryIndex`).
177
- - Leaves imports/exports and interop untouched (no export bag, no idiomaticExports, no live-binding synthesis, no helpers like `__requireResolve`).
178
- - CJS→ESM: `require.resolve` maps to `import.meta.resolve` (URL return, ESM resolver) and may differ from CJS resolution. ESM→CJS: `import.meta` maps to CJS globals; no import lowering.
179
-
180
174
  ### Diagnostics callback example
181
175
 
182
176
  Pass a `diagnostics` callback to surface CJS→ESM edge cases (mixed `module.exports`/`exports`, top-level `return`, legacy `require.cache`/`require.extensions`, live-binding reassignments, string-literal export names):
@@ -213,20 +207,9 @@ TypeScript reports asymmetric module-global errors (e.g., `import.meta` in CJS,
213
207
 
214
208
  Minimal flow:
215
209
 
216
- ```js
217
- import { glob } from 'glob'
218
- import { transform } from '@knighted/module'
219
-
220
- const files = await glob('src/**/*.{ts,js,mts,cts}', { ignore: 'node_modules/**' })
221
-
222
- for (const file of files) {
223
- await transform(file, {
224
- target: 'commonjs', // or 'module' when raising CJS → ESM
225
- inPlace: true,
226
- transformSyntax: true,
227
- })
228
- }
229
- // then run `tsc`
210
+ ```bash
211
+ dub -t commonjs "src/**/*.{ts,js,mts,cts}" --ignore node_modules/** --transform-syntax globals-only --in-place
212
+ tsc
230
213
  ```
231
214
 
232
- This pre-`tsc` step removes the flagged globals in the compiled orientation; runtime semantics still match the target build.
215
+ This pre-`tsc` step rewrites globals-only (keeps import/export syntax) so the TypeScript checker sees already-rewritten sources; runtime semantics still match the target build.
@@ -0,0 +1,553 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.runCli = void 0;
8
+ var _nodeProcess = require("node:process");
9
+ var _nodeUtil = require("node:util");
10
+ var _promises = require("node:fs/promises");
11
+ var _nodePath = require("node:path");
12
+ var _nodeModule = require("node:module");
13
+ var _glob = require("glob");
14
+ var _module = require("./module.cjs");
15
+ var _parse = require("./parse.cjs");
16
+ var _format = require("./format.cjs");
17
+ var _specifier = require("./specifier.cjs");
18
+ var _lang = require("./utils/lang.cjs");
19
+ const defaultOptions = {
20
+ target: 'commonjs',
21
+ sourceType: 'auto',
22
+ transformSyntax: true,
23
+ liveBindings: 'strict',
24
+ rewriteSpecifier: undefined,
25
+ appendJsExtension: undefined,
26
+ appendDirectoryIndex: 'index.js',
27
+ dirFilename: 'inject',
28
+ importMeta: 'shim',
29
+ importMetaMain: 'shim',
30
+ requireMainStrategy: 'import-meta-main',
31
+ detectCircularRequires: 'off',
32
+ requireSource: 'builtin',
33
+ nestedRequireStrategy: 'create-require',
34
+ cjsDefault: 'auto',
35
+ idiomaticExports: 'safe',
36
+ importMetaPrelude: 'auto',
37
+ topLevelAwait: 'error',
38
+ cwd: undefined,
39
+ out: undefined,
40
+ inPlace: false
41
+ };
42
+ const icons = {
43
+ info: 'i',
44
+ warn: '⚠',
45
+ error: '✖',
46
+ success: '✔'
47
+ };
48
+ const codes = {
49
+ reset: '\u001b[0m',
50
+ bold: '\u001b[1m',
51
+ dim: '\u001b[2m',
52
+ red: '\u001b[31m',
53
+ yellow: '\u001b[33m',
54
+ green: '\u001b[32m',
55
+ cyan: '\u001b[36m'
56
+ };
57
+ const colorize = enabled => {
58
+ if (!enabled) {
59
+ return {
60
+ bold: v => v,
61
+ dim: v => v,
62
+ red: v => v,
63
+ yellow: v => v,
64
+ green: v => v,
65
+ cyan: v => v
66
+ };
67
+ }
68
+ const wrap = code => v => `${code}${v}${codes.reset}`;
69
+ return {
70
+ bold: wrap(codes.bold),
71
+ dim: wrap(codes.dim),
72
+ red: wrap(codes.red),
73
+ yellow: wrap(codes.yellow),
74
+ green: wrap(codes.green),
75
+ cyan: wrap(codes.cyan)
76
+ };
77
+ };
78
+ const builtinSpecifiers = new Set(_nodeModule.builtinModules.map(mod => mod.startsWith('node:') ? mod.slice(5) : mod).flatMap(mod => {
79
+ const parts = mod.split('/');
80
+ const base = parts[0];
81
+ return parts.length > 1 ? [mod, base] : [mod];
82
+ }));
83
+ const collapseSpecifier = value => value.replace(/['"`+)\s]|new String\(/g, '');
84
+ const appendExtensionIfNeeded = (value, mode, dirIndex) => {
85
+ if (mode === 'off') return;
86
+ const collapsed = collapseSpecifier(value);
87
+ const isRelative = /^(?:\.\.?)\//.test(collapsed);
88
+ if (!isRelative) return;
89
+ const base = collapsed.split(/[?#]/)[0];
90
+ if (!base) return;
91
+ if (base.endsWith('/')) {
92
+ if (!dirIndex) return;
93
+ return `${value}${dirIndex}`;
94
+ }
95
+ const lastSegment = base.split('/').pop() ?? '';
96
+ if (lastSegment.includes('.')) return;
97
+ return `${value}.js`;
98
+ };
99
+ const rewriteSpecifierValue = (value, rewriteSpecifier) => {
100
+ if (!rewriteSpecifier) return;
101
+ if (typeof rewriteSpecifier === 'function') {
102
+ return rewriteSpecifier(value) ?? undefined;
103
+ }
104
+ const collapsed = collapseSpecifier(value);
105
+ const relative = /^(?:\.\.?\/)/;
106
+ if (relative.test(collapsed)) {
107
+ return value.replace(/(.+)\.(?:m|c)?(?:j|t)sx?([)'"]*)?$/, `$1${rewriteSpecifier}$2`);
108
+ }
109
+ };
110
+ const normalizeBuiltinSpecifier = value => {
111
+ const collapsed = collapseSpecifier(value);
112
+ if (!collapsed) return;
113
+ const specPart = collapsed.split(/[?#]/)[0] ?? '';
114
+ if (/^(?:\.\.?(?:\/)|\/)/.test(specPart)) return;
115
+ if (/^[a-zA-Z][a-zA-Z+.-]*:/.test(specPart) && !specPart.startsWith('node:')) return;
116
+ const bare = specPart.startsWith('node:') ? specPart.slice(5) : specPart;
117
+ const base = bare.split('/')[0] ?? '';
118
+ if (!builtinSpecifiers.has(bare) && !builtinSpecifiers.has(base)) return;
119
+ if (specPart.startsWith('node:')) return;
120
+ const quote = /^['"`]/.exec(value)?.[0] ?? '';
121
+ return quote ? `${quote}node:${value.slice(quote.length)}` : `node:${value}`;
122
+ };
123
+ const optionsTable = [{
124
+ long: 'target',
125
+ short: 't',
126
+ type: 'string',
127
+ desc: 'Output format (module|commonjs)'
128
+ }, {
129
+ long: 'transform-syntax',
130
+ short: 'x',
131
+ type: 'string',
132
+ desc: 'Syntax transforms (true|false|globals-only)'
133
+ }, {
134
+ long: 'rewrite-specifier',
135
+ short: 'r',
136
+ type: 'string',
137
+ desc: 'Rewrite import specifiers (.js/.mjs/.cjs/.ts/.mts/.cts)'
138
+ }, {
139
+ long: 'append-js-extension',
140
+ short: 'j',
141
+ type: 'string',
142
+ desc: 'Append .js to relative imports (off|relative-only|all)'
143
+ }, {
144
+ long: 'append-directory-index',
145
+ short: 'i',
146
+ type: 'string',
147
+ desc: 'Append directory index (e.g. index.js) or false'
148
+ }, {
149
+ long: 'detect-circular-requires',
150
+ short: 'c',
151
+ type: 'string',
152
+ desc: 'Warn/error on circular require (off|warn|error)'
153
+ }, {
154
+ long: 'top-level-await',
155
+ short: 'a',
156
+ type: 'string',
157
+ desc: 'TLA handling (error|wrap|preserve)'
158
+ }, {
159
+ long: 'cjs-default',
160
+ short: 'd',
161
+ type: 'string',
162
+ desc: 'Default interop (module-exports|auto|none)'
163
+ }, {
164
+ long: 'idiomatic-exports',
165
+ short: 'e',
166
+ type: 'string',
167
+ desc: 'Emit idiomatic exports when safe (off|safe|aggressive)'
168
+ }, {
169
+ long: 'import-meta-prelude',
170
+ short: 'm',
171
+ type: 'string',
172
+ desc: 'Emit import.meta prelude (off|auto|on)'
173
+ }, {
174
+ long: 'nested-require-strategy',
175
+ short: 'n',
176
+ type: 'string',
177
+ desc: 'Rewrite nested require (create-require|dynamic-import)'
178
+ }, {
179
+ long: 'require-main-strategy',
180
+ short: 'R',
181
+ type: 'string',
182
+ desc: 'Detect main (import-meta-main|realpath)'
183
+ }, {
184
+ long: 'live-bindings',
185
+ short: 'l',
186
+ type: 'string',
187
+ desc: 'Live binding strategy (strict|loose|off)'
188
+ }, {
189
+ long: 'out-dir',
190
+ short: 'o',
191
+ type: 'string',
192
+ desc: 'Write outputs to a directory mirror'
193
+ }, {
194
+ long: 'in-place',
195
+ short: 'p',
196
+ type: 'boolean',
197
+ desc: 'Rewrite files in place'
198
+ }, {
199
+ long: 'dry-run',
200
+ short: 'y',
201
+ type: 'boolean',
202
+ desc: 'Do not write files; report planned changes'
203
+ }, {
204
+ long: 'list',
205
+ short: 'L',
206
+ type: 'boolean',
207
+ desc: 'List files that would change'
208
+ }, {
209
+ long: 'summary',
210
+ short: 's',
211
+ type: 'boolean',
212
+ desc: 'Print a summary of work performed'
213
+ }, {
214
+ long: 'json',
215
+ short: 'J',
216
+ type: 'boolean',
217
+ desc: 'Emit machine-readable JSON summary/diagnostics'
218
+ }, {
219
+ long: 'cwd',
220
+ short: 'C',
221
+ type: 'string',
222
+ desc: 'Working directory for resolving files/out paths'
223
+ }, {
224
+ long: 'stdin-filename',
225
+ short: 'f',
226
+ type: 'string',
227
+ desc: 'Virtual filename when reading from stdin'
228
+ }, {
229
+ long: 'ignore',
230
+ short: 'g',
231
+ type: 'string',
232
+ desc: 'Glob pattern(s) to ignore (repeatable)'
233
+ }, {
234
+ long: 'help',
235
+ short: 'h',
236
+ type: 'boolean',
237
+ desc: 'Show help'
238
+ }, {
239
+ long: 'version',
240
+ short: 'v',
241
+ type: 'boolean',
242
+ desc: 'Show version'
243
+ }];
244
+ const buildHelp = enableColor => {
245
+ const c = colorize(enableColor);
246
+ const maxFlagLength = Math.max(...optionsTable.map(opt => ` -${opt.short}, --${opt.long}`.length));
247
+ const lines = [`${c.bold('Usage:')} dub [options] <files...>`, '', 'Examples:', ' dub -t module src/index.cjs --out-dir dist', ' dub -t commonjs src/**/*.mjs -p', ' cat input.cjs | dub -t module --stdin-filename input.cjs', '', 'Options:'];
248
+ for (const opt of optionsTable) {
249
+ const flag = ` -${opt.short}, --${opt.long}`;
250
+ const pad = ' '.repeat(Math.max(2, maxFlagLength - flag.length + 2));
251
+ lines.push(`${c.bold(flag)}${pad}${opt.desc}`);
252
+ }
253
+ return `${lines.join('\n')}\n`;
254
+ };
255
+ const parseEnum = (value, allowed) => {
256
+ if (value === undefined) return undefined;
257
+ return allowed.includes(value) ? value : undefined;
258
+ };
259
+ const parseTransformSyntax = value => {
260
+ if (value === undefined) return defaultOptions.transformSyntax;
261
+ if (value === 'globals-only') return 'globals-only';
262
+ if (value === 'false') return false;
263
+ if (value === 'true') return true;
264
+ return defaultOptions.transformSyntax;
265
+ };
266
+ const parseAppendDirectoryIndex = value => {
267
+ if (value === undefined) return undefined;
268
+ if (value === 'false') return false;
269
+ return value;
270
+ };
271
+ const toModuleOptions = values => {
272
+ const target = parseEnum(values.target, ['module', 'commonjs']) ?? defaultOptions.target;
273
+ const transformSyntax = parseTransformSyntax(values['transform-syntax']);
274
+ const appendJsExtension = parseEnum(values['append-js-extension'], ['off', 'relative-only', 'all']);
275
+ const appendDirectoryIndex = parseAppendDirectoryIndex(values['append-directory-index']);
276
+ const opts = {
277
+ ...defaultOptions,
278
+ target,
279
+ transformSyntax,
280
+ rewriteSpecifier: values['rewrite-specifier'] ?? undefined,
281
+ appendJsExtension: appendJsExtension,
282
+ appendDirectoryIndex,
283
+ detectCircularRequires: parseEnum(values['detect-circular-requires'], ['off', 'warn', 'error']) ?? defaultOptions.detectCircularRequires,
284
+ topLevelAwait: parseEnum(values['top-level-await'], ['error', 'wrap', 'preserve']) ?? defaultOptions.topLevelAwait,
285
+ cjsDefault: parseEnum(values['cjs-default'], ['module-exports', 'auto', 'none']) ?? defaultOptions.cjsDefault,
286
+ idiomaticExports: parseEnum(values['idiomatic-exports'], ['off', 'safe', 'aggressive']) ?? defaultOptions.idiomaticExports,
287
+ importMetaPrelude: parseEnum(values['import-meta-prelude'], ['off', 'auto', 'on']) ?? defaultOptions.importMetaPrelude,
288
+ nestedRequireStrategy: parseEnum(values['nested-require-strategy'], ['create-require', 'dynamic-import']) ?? defaultOptions.nestedRequireStrategy,
289
+ requireMainStrategy: parseEnum(values['require-main-strategy'], ['import-meta-main', 'realpath']) ?? defaultOptions.requireMainStrategy,
290
+ liveBindings: parseEnum(values['live-bindings'], ['strict', 'loose', 'off']) ?? defaultOptions.liveBindings,
291
+ cwd: values.cwd ? (0, _nodePath.resolve)(String(values.cwd)) : defaultOptions.cwd
292
+ };
293
+ return opts;
294
+ };
295
+ const readStdin = async stdin => {
296
+ const chunks = [];
297
+ for await (const chunk of stdin) {
298
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
299
+ }
300
+ return Buffer.concat(chunks).toString('utf8');
301
+ };
302
+ const expandFiles = async (patterns, cwd, ignore) => {
303
+ const files = new Set();
304
+ for (const pattern of patterns) {
305
+ const matches = await (0, _glob.glob)(pattern, {
306
+ cwd,
307
+ absolute: true,
308
+ nodir: true,
309
+ windowsPathsNoEscape: true,
310
+ ignore
311
+ });
312
+ for (const m of matches) files.add((0, _nodePath.resolve)(m));
313
+ }
314
+ return [...files];
315
+ };
316
+ const makeLogger = (stdout, stderr) => {
317
+ const enableColor = stdout.isTTY ?? stderr.isTTY ?? false;
318
+ const c = colorize(enableColor);
319
+ const log = (kind, message, stream) => {
320
+ const icon = icons[kind];
321
+ const colored = kind === 'error' ? c.red(message) : kind === 'warn' ? c.yellow(message) : kind === 'success' ? c.green(message) : c.cyan(message);
322
+ stream.write(`${icon} ${colored}\n`);
323
+ };
324
+ return {
325
+ info: msg => log('info', msg, stdout),
326
+ warn: msg => log('warn', msg, stderr),
327
+ error: msg => log('error', msg, stderr),
328
+ success: msg => log('success', msg, stdout),
329
+ color: c
330
+ };
331
+ };
332
+ const applySpecifierUpdates = async (source, filename, opts, appendMode, dirIndex) => {
333
+ if (!opts.rewriteSpecifier && appendMode === 'off' && !dirIndex) return source;
334
+ const lang = (0, _lang.getLangFromExt)(filename);
335
+ const updated = await _specifier.specifier.updateSrc(source, lang, spec => {
336
+ const normalized = normalizeBuiltinSpecifier(spec.value);
337
+ const rewritten = rewriteSpecifierValue(normalized ?? spec.value, opts.rewriteSpecifier);
338
+ const baseValue = rewritten ?? normalized ?? spec.value;
339
+ const appended = appendExtensionIfNeeded(baseValue, appendMode, dirIndex);
340
+ return appended ?? rewritten ?? normalized ?? undefined;
341
+ });
342
+ return updated;
343
+ };
344
+ const transformVirtual = async (source, filename, opts) => {
345
+ const ast = (0, _parse.parse)(filename, source);
346
+ let output = await (0, _format.format)(source, ast, {
347
+ ...opts,
348
+ filePath: filename
349
+ });
350
+ const appendMode = opts.appendJsExtension ?? (opts.target === 'module' ? 'relative-only' : 'off');
351
+ const dirIndex = opts.appendDirectoryIndex === undefined ? 'index.js' : opts.appendDirectoryIndex;
352
+ output = await applySpecifierUpdates(output, filename, opts, appendMode, dirIndex);
353
+ return output;
354
+ };
355
+ const summarizeDiagnostics = diags => {
356
+ let warnings = 0;
357
+ let errors = 0;
358
+ for (const d of diags) {
359
+ if (d.level === 'warning') warnings += 1;
360
+ if (d.level === 'error') errors += 1;
361
+ }
362
+ return {
363
+ warnings,
364
+ errors
365
+ };
366
+ };
367
+ const runFiles = async (files, moduleOpts, io, flags) => {
368
+ const results = [];
369
+ const logger = makeLogger(io.stdout, io.stderr);
370
+ for (const file of files) {
371
+ const diagnostics = [];
372
+ const original = await (0, _promises.readFile)(file, 'utf8');
373
+ const outPath = flags.outDir ? (0, _nodePath.join)(flags.outDir, (0, _nodePath.relative)(moduleOpts.cwd ?? process.cwd(), file)) : undefined;
374
+ const perFileOpts = {
375
+ ...moduleOpts,
376
+ diagnostics: diag => diagnostics.push(diag),
377
+ out: undefined,
378
+ inPlace: false,
379
+ filePath: file
380
+ };
381
+ let writeTarget;
382
+ if (!flags.dryRun && !flags.list) {
383
+ if (flags.inPlace) {
384
+ perFileOpts.inPlace = true;
385
+ } else if (outPath) {
386
+ writeTarget = outPath;
387
+ perFileOpts.out = outPath;
388
+ await (0, _promises.mkdir)((0, _nodePath.dirname)(outPath), {
389
+ recursive: true
390
+ });
391
+ } else if (!flags.allowStdout) {
392
+ logger.error('Specify --out-dir or --in-place when transforming files');
393
+ return {
394
+ code: 2,
395
+ results
396
+ };
397
+ }
398
+ }
399
+ const output = await (0, _module.transform)(file, perFileOpts);
400
+ const changed = output !== original;
401
+ if (flags.list && changed) {
402
+ logger.info(file);
403
+ }
404
+ if (!flags.dryRun && !flags.list && !writeTarget && !perFileOpts.inPlace) {
405
+ io.stdout.write(output);
406
+ }
407
+ results.push({
408
+ filePath: file,
409
+ changed,
410
+ diagnostics
411
+ });
412
+ const counts = summarizeDiagnostics(diagnostics);
413
+ if (!flags.json) {
414
+ for (const diag of diagnostics) {
415
+ const prefix = diag.level === 'error' ? logger.error : logger.warn;
416
+ const loc = diag.loc ? ` [${diag.loc.start}-${diag.loc.end}]` : '';
417
+ prefix(`${diag.code}: ${diag.message}${loc}`);
418
+ }
419
+ }
420
+ if (counts.errors > 0) {
421
+ return {
422
+ code: 1,
423
+ results
424
+ };
425
+ }
426
+ }
427
+ if (flags.summary && !flags.json) {
428
+ const changedCount = results.filter(r => r.changed).length;
429
+ logger.success(`Processed ${results.length} file(s); changed ${changedCount}`);
430
+ }
431
+ return {
432
+ code: 0,
433
+ results
434
+ };
435
+ };
436
+ const runCli = async ({
437
+ argv = process.argv.slice(2),
438
+ stdin = _nodeProcess.stdin,
439
+ stdout = _nodeProcess.stdout,
440
+ stderr = _nodeProcess.stderr
441
+ } = {}) => {
442
+ const {
443
+ values,
444
+ positionals
445
+ } = (0, _nodeUtil.parseArgs)({
446
+ args: argv,
447
+ allowPositionals: true,
448
+ options: Object.fromEntries(optionsTable.map(opt => [opt.long, {
449
+ type: opt.type,
450
+ short: opt.short
451
+ }]))
452
+ });
453
+ const logger = makeLogger(stdout, stderr);
454
+ if (values.help) {
455
+ stdout.write(buildHelp(stdout.isTTY ?? false));
456
+ return 0;
457
+ }
458
+ if (values.version) {
459
+ const pkg = JSON.parse(await (0, _promises.readFile)(new URL('../package.json', import.meta.url), 'utf8'));
460
+ stdout.write(`${pkg.version}\n`);
461
+ return 0;
462
+ }
463
+ const moduleOpts = toModuleOptions(values);
464
+ const cwd = moduleOpts.cwd ?? process.cwd();
465
+ const allowStdout = positionals.length <= 1;
466
+ const fromStdin = positionals.length === 0 || positionals.includes('-');
467
+ const patterns = positionals.filter(p => p !== '-');
468
+ const ignoreValues = values.ignore;
469
+ const ignore = ignoreValues ? (Array.isArray(ignoreValues) ? ignoreValues : [ignoreValues]).map(String) : undefined;
470
+ const outDir = values['out-dir'] ? (0, _nodePath.resolve)(cwd, String(values['out-dir'])) : undefined;
471
+ const inPlace = Boolean(values['in-place']);
472
+ const dryRun = Boolean(values['dry-run']);
473
+ const list = Boolean(values.list);
474
+ const summary = Boolean(values.summary);
475
+ const json = Boolean(values.json);
476
+ if (outDir && inPlace) {
477
+ logger.error('Choose either --out-dir or --in-place, not both');
478
+ return 2;
479
+ }
480
+ if (fromStdin && (outDir || inPlace)) {
481
+ logger.error('Cannot combine stdin with --out-dir or --in-place; output goes to stdout');
482
+ return 2;
483
+ }
484
+ const files = await expandFiles(patterns, cwd, ignore);
485
+ if (!fromStdin && files.length === 0) {
486
+ logger.error('No input files were provided or matched');
487
+ return 2;
488
+ }
489
+ const tasks = [];
490
+ if (fromStdin) {
491
+ const virtualName = values['stdin-filename'] ?? 'stdin.js';
492
+ const source = await readStdin(stdin);
493
+ const diagnostics = [];
494
+ const output = await transformVirtual(source, virtualName, {
495
+ ...moduleOpts,
496
+ diagnostics: diag => diagnostics.push(diag),
497
+ filePath: virtualName,
498
+ cwd
499
+ });
500
+ tasks.push({
501
+ filePath: virtualName,
502
+ changed: true,
503
+ diagnostics
504
+ });
505
+ stdout.write(output);
506
+ if (!json) {
507
+ for (const diag of diagnostics) {
508
+ const prefix = diag.level === 'error' ? logger.error : logger.warn;
509
+ const loc = diag.loc ? ` [${diag.loc.start}-${diag.loc.end}]` : '';
510
+ prefix(`${diag.code}: ${diag.message}${loc}`);
511
+ }
512
+ }
513
+ const diagSummary = summarizeDiagnostics(diagnostics);
514
+ if (diagSummary.errors > 0) return 1;
515
+ }
516
+ if (files.length) {
517
+ const result = await runFiles(files, {
518
+ ...moduleOpts,
519
+ cwd
520
+ }, {
521
+ stdout,
522
+ stderr
523
+ }, {
524
+ dryRun,
525
+ list,
526
+ summary,
527
+ json,
528
+ outDir,
529
+ inPlace,
530
+ allowStdout
531
+ });
532
+ if (typeof result.code === 'number' && result.code !== 0) return result.code;
533
+ tasks.push(...result.results);
534
+ }
535
+ if (json) {
536
+ const summaryDiag = summarizeDiagnostics(tasks.flatMap(t => t.diagnostics));
537
+ stdout.write(`${JSON.stringify({
538
+ files: tasks,
539
+ summary: summaryDiag
540
+ }, null, 2)}\n`);
541
+ }
542
+ return 0;
543
+ };
544
+ exports.runCli = runCli;
545
+ if (import.meta.main) {
546
+ runCli().then(code => {
547
+ if (code !== 0) process.exit(code);
548
+ }, err => {
549
+ // eslint-disable-next-line no-console -- CLI surface
550
+ console.error(err);
551
+ process.exit(1);
552
+ });
553
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { stdin as defaultStdin } from 'node:process';
3
+ type StreamLike = {
4
+ isTTY?: boolean;
5
+ write: (chunk: string | Uint8Array) => unknown;
6
+ };
7
+ type CliOptions = {
8
+ argv?: string[];
9
+ stdin?: typeof defaultStdin;
10
+ stdout?: StreamLike;
11
+ stderr?: StreamLike;
12
+ };
13
+ declare const runCli: ({ argv, stdin, stdout, stderr, }?: CliOptions) => Promise<number>;
14
+ export { runCli };
@@ -580,7 +580,10 @@ const format = async (src, ast, opts) => {
580
580
  seen.add(propName);
581
581
  if (rhs.type === 'Identifier') {
582
582
  const rhsId = rhsSourceFor(rhs);
583
- if (rhsId === rhs.name) {
583
+ const rhsName = rhs.name;
584
+ if (rhsId === rhsName && rhsName === propName) {
585
+ exportsOut.push(`export { ${propName} };`);
586
+ } else if (rhsId === rhsName) {
584
587
  exportsOut.push(`export { ${rhsId} as ${propName} };`);
585
588
  } else {
586
589
  exportsOut.push(`export const ${propName} = ${rhsId};`);
@@ -589,9 +592,15 @@ const format = async (src, ast, opts) => {
589
592
  exportsOut.push(`export const ${propName} = ${rhsSrc};`);
590
593
  }
591
594
  }
595
+
596
+ // Trim trailing whitespace and one optional semicolon so the idiomatic export
597
+ // replacement does not leave the original `;` behind (avoids emitting `;;`).
598
+ let end = write.end;
599
+ while (end < src.length && (src[end] === ' ' || src[end] === '\t')) end++;
600
+ if (end < src.length && src[end] === ';') end++;
592
601
  replacements.push({
593
602
  start: write.start,
594
- end: write.end
603
+ end
595
604
  });
596
605
  }
597
606
  if (!seen.size) return {
@@ -49,6 +49,9 @@ const metaProperty = (node, parent, src, options) => {
49
49
  case 'main':
50
50
  src.update(parent.start, parent.end, importMetaMainExpr(options.importMetaMain));
51
51
  break;
52
+ default:
53
+ src.update(parent.start, parent.end, `module.${parent.property.name}`);
54
+ break;
52
55
  }
53
56
  }
54
57
  }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { stdin as defaultStdin } from 'node:process';
3
+ type StreamLike = {
4
+ isTTY?: boolean;
5
+ write: (chunk: string | Uint8Array) => unknown;
6
+ };
7
+ type CliOptions = {
8
+ argv?: string[];
9
+ stdin?: typeof defaultStdin;
10
+ stdout?: StreamLike;
11
+ stderr?: StreamLike;
12
+ };
13
+ declare const runCli: ({ argv, stdin, stdout, stderr, }?: CliOptions) => Promise<number>;
14
+ export { runCli };
package/dist/cli.js ADDED
@@ -0,0 +1,547 @@
1
+ #!/usr/bin/env node
2
+ import { stdin as defaultStdin, stdout as defaultStdout, stderr as defaultStderr } from 'node:process';
3
+ import { parseArgs } from 'node:util';
4
+ import { readFile, mkdir } from 'node:fs/promises';
5
+ import { dirname, resolve, relative, join } from 'node:path';
6
+ import { builtinModules } from 'node:module';
7
+ import { glob } from 'glob';
8
+ import { transform } from './module.js';
9
+ import { parse } from './parse.js';
10
+ import { format } from './format.js';
11
+ import { specifier } from './specifier.js';
12
+ import { getLangFromExt } from './utils/lang.js';
13
+ const defaultOptions = {
14
+ target: 'commonjs',
15
+ sourceType: 'auto',
16
+ transformSyntax: true,
17
+ liveBindings: 'strict',
18
+ rewriteSpecifier: undefined,
19
+ appendJsExtension: undefined,
20
+ appendDirectoryIndex: 'index.js',
21
+ dirFilename: 'inject',
22
+ importMeta: 'shim',
23
+ importMetaMain: 'shim',
24
+ requireMainStrategy: 'import-meta-main',
25
+ detectCircularRequires: 'off',
26
+ requireSource: 'builtin',
27
+ nestedRequireStrategy: 'create-require',
28
+ cjsDefault: 'auto',
29
+ idiomaticExports: 'safe',
30
+ importMetaPrelude: 'auto',
31
+ topLevelAwait: 'error',
32
+ cwd: undefined,
33
+ out: undefined,
34
+ inPlace: false
35
+ };
36
+ const icons = {
37
+ info: 'i',
38
+ warn: '⚠',
39
+ error: '✖',
40
+ success: '✔'
41
+ };
42
+ const codes = {
43
+ reset: '\u001b[0m',
44
+ bold: '\u001b[1m',
45
+ dim: '\u001b[2m',
46
+ red: '\u001b[31m',
47
+ yellow: '\u001b[33m',
48
+ green: '\u001b[32m',
49
+ cyan: '\u001b[36m'
50
+ };
51
+ const colorize = enabled => {
52
+ if (!enabled) {
53
+ return {
54
+ bold: v => v,
55
+ dim: v => v,
56
+ red: v => v,
57
+ yellow: v => v,
58
+ green: v => v,
59
+ cyan: v => v
60
+ };
61
+ }
62
+ const wrap = code => v => `${code}${v}${codes.reset}`;
63
+ return {
64
+ bold: wrap(codes.bold),
65
+ dim: wrap(codes.dim),
66
+ red: wrap(codes.red),
67
+ yellow: wrap(codes.yellow),
68
+ green: wrap(codes.green),
69
+ cyan: wrap(codes.cyan)
70
+ };
71
+ };
72
+ const builtinSpecifiers = new Set(builtinModules.map(mod => mod.startsWith('node:') ? mod.slice(5) : mod).flatMap(mod => {
73
+ const parts = mod.split('/');
74
+ const base = parts[0];
75
+ return parts.length > 1 ? [mod, base] : [mod];
76
+ }));
77
+ const collapseSpecifier = value => value.replace(/['"`+)\s]|new String\(/g, '');
78
+ const appendExtensionIfNeeded = (value, mode, dirIndex) => {
79
+ if (mode === 'off') return;
80
+ const collapsed = collapseSpecifier(value);
81
+ const isRelative = /^(?:\.\.?)\//.test(collapsed);
82
+ if (!isRelative) return;
83
+ const base = collapsed.split(/[?#]/)[0];
84
+ if (!base) return;
85
+ if (base.endsWith('/')) {
86
+ if (!dirIndex) return;
87
+ return `${value}${dirIndex}`;
88
+ }
89
+ const lastSegment = base.split('/').pop() ?? '';
90
+ if (lastSegment.includes('.')) return;
91
+ return `${value}.js`;
92
+ };
93
+ const rewriteSpecifierValue = (value, rewriteSpecifier) => {
94
+ if (!rewriteSpecifier) return;
95
+ if (typeof rewriteSpecifier === 'function') {
96
+ return rewriteSpecifier(value) ?? undefined;
97
+ }
98
+ const collapsed = collapseSpecifier(value);
99
+ const relative = /^(?:\.\.?\/)/;
100
+ if (relative.test(collapsed)) {
101
+ return value.replace(/(.+)\.(?:m|c)?(?:j|t)sx?([)'"]*)?$/, `$1${rewriteSpecifier}$2`);
102
+ }
103
+ };
104
+ const normalizeBuiltinSpecifier = value => {
105
+ const collapsed = collapseSpecifier(value);
106
+ if (!collapsed) return;
107
+ const specPart = collapsed.split(/[?#]/)[0] ?? '';
108
+ if (/^(?:\.\.?(?:\/)|\/)/.test(specPart)) return;
109
+ if (/^[a-zA-Z][a-zA-Z+.-]*:/.test(specPart) && !specPart.startsWith('node:')) return;
110
+ const bare = specPart.startsWith('node:') ? specPart.slice(5) : specPart;
111
+ const base = bare.split('/')[0] ?? '';
112
+ if (!builtinSpecifiers.has(bare) && !builtinSpecifiers.has(base)) return;
113
+ if (specPart.startsWith('node:')) return;
114
+ const quote = /^['"`]/.exec(value)?.[0] ?? '';
115
+ return quote ? `${quote}node:${value.slice(quote.length)}` : `node:${value}`;
116
+ };
117
+ const optionsTable = [{
118
+ long: 'target',
119
+ short: 't',
120
+ type: 'string',
121
+ desc: 'Output format (module|commonjs)'
122
+ }, {
123
+ long: 'transform-syntax',
124
+ short: 'x',
125
+ type: 'string',
126
+ desc: 'Syntax transforms (true|false|globals-only)'
127
+ }, {
128
+ long: 'rewrite-specifier',
129
+ short: 'r',
130
+ type: 'string',
131
+ desc: 'Rewrite import specifiers (.js/.mjs/.cjs/.ts/.mts/.cts)'
132
+ }, {
133
+ long: 'append-js-extension',
134
+ short: 'j',
135
+ type: 'string',
136
+ desc: 'Append .js to relative imports (off|relative-only|all)'
137
+ }, {
138
+ long: 'append-directory-index',
139
+ short: 'i',
140
+ type: 'string',
141
+ desc: 'Append directory index (e.g. index.js) or false'
142
+ }, {
143
+ long: 'detect-circular-requires',
144
+ short: 'c',
145
+ type: 'string',
146
+ desc: 'Warn/error on circular require (off|warn|error)'
147
+ }, {
148
+ long: 'top-level-await',
149
+ short: 'a',
150
+ type: 'string',
151
+ desc: 'TLA handling (error|wrap|preserve)'
152
+ }, {
153
+ long: 'cjs-default',
154
+ short: 'd',
155
+ type: 'string',
156
+ desc: 'Default interop (module-exports|auto|none)'
157
+ }, {
158
+ long: 'idiomatic-exports',
159
+ short: 'e',
160
+ type: 'string',
161
+ desc: 'Emit idiomatic exports when safe (off|safe|aggressive)'
162
+ }, {
163
+ long: 'import-meta-prelude',
164
+ short: 'm',
165
+ type: 'string',
166
+ desc: 'Emit import.meta prelude (off|auto|on)'
167
+ }, {
168
+ long: 'nested-require-strategy',
169
+ short: 'n',
170
+ type: 'string',
171
+ desc: 'Rewrite nested require (create-require|dynamic-import)'
172
+ }, {
173
+ long: 'require-main-strategy',
174
+ short: 'R',
175
+ type: 'string',
176
+ desc: 'Detect main (import-meta-main|realpath)'
177
+ }, {
178
+ long: 'live-bindings',
179
+ short: 'l',
180
+ type: 'string',
181
+ desc: 'Live binding strategy (strict|loose|off)'
182
+ }, {
183
+ long: 'out-dir',
184
+ short: 'o',
185
+ type: 'string',
186
+ desc: 'Write outputs to a directory mirror'
187
+ }, {
188
+ long: 'in-place',
189
+ short: 'p',
190
+ type: 'boolean',
191
+ desc: 'Rewrite files in place'
192
+ }, {
193
+ long: 'dry-run',
194
+ short: 'y',
195
+ type: 'boolean',
196
+ desc: 'Do not write files; report planned changes'
197
+ }, {
198
+ long: 'list',
199
+ short: 'L',
200
+ type: 'boolean',
201
+ desc: 'List files that would change'
202
+ }, {
203
+ long: 'summary',
204
+ short: 's',
205
+ type: 'boolean',
206
+ desc: 'Print a summary of work performed'
207
+ }, {
208
+ long: 'json',
209
+ short: 'J',
210
+ type: 'boolean',
211
+ desc: 'Emit machine-readable JSON summary/diagnostics'
212
+ }, {
213
+ long: 'cwd',
214
+ short: 'C',
215
+ type: 'string',
216
+ desc: 'Working directory for resolving files/out paths'
217
+ }, {
218
+ long: 'stdin-filename',
219
+ short: 'f',
220
+ type: 'string',
221
+ desc: 'Virtual filename when reading from stdin'
222
+ }, {
223
+ long: 'ignore',
224
+ short: 'g',
225
+ type: 'string',
226
+ desc: 'Glob pattern(s) to ignore (repeatable)'
227
+ }, {
228
+ long: 'help',
229
+ short: 'h',
230
+ type: 'boolean',
231
+ desc: 'Show help'
232
+ }, {
233
+ long: 'version',
234
+ short: 'v',
235
+ type: 'boolean',
236
+ desc: 'Show version'
237
+ }];
238
+ const buildHelp = enableColor => {
239
+ const c = colorize(enableColor);
240
+ const maxFlagLength = Math.max(...optionsTable.map(opt => ` -${opt.short}, --${opt.long}`.length));
241
+ const lines = [`${c.bold('Usage:')} dub [options] <files...>`, '', 'Examples:', ' dub -t module src/index.cjs --out-dir dist', ' dub -t commonjs src/**/*.mjs -p', ' cat input.cjs | dub -t module --stdin-filename input.cjs', '', 'Options:'];
242
+ for (const opt of optionsTable) {
243
+ const flag = ` -${opt.short}, --${opt.long}`;
244
+ const pad = ' '.repeat(Math.max(2, maxFlagLength - flag.length + 2));
245
+ lines.push(`${c.bold(flag)}${pad}${opt.desc}`);
246
+ }
247
+ return `${lines.join('\n')}\n`;
248
+ };
249
+ const parseEnum = (value, allowed) => {
250
+ if (value === undefined) return undefined;
251
+ return allowed.includes(value) ? value : undefined;
252
+ };
253
+ const parseTransformSyntax = value => {
254
+ if (value === undefined) return defaultOptions.transformSyntax;
255
+ if (value === 'globals-only') return 'globals-only';
256
+ if (value === 'false') return false;
257
+ if (value === 'true') return true;
258
+ return defaultOptions.transformSyntax;
259
+ };
260
+ const parseAppendDirectoryIndex = value => {
261
+ if (value === undefined) return undefined;
262
+ if (value === 'false') return false;
263
+ return value;
264
+ };
265
+ const toModuleOptions = values => {
266
+ const target = parseEnum(values.target, ['module', 'commonjs']) ?? defaultOptions.target;
267
+ const transformSyntax = parseTransformSyntax(values['transform-syntax']);
268
+ const appendJsExtension = parseEnum(values['append-js-extension'], ['off', 'relative-only', 'all']);
269
+ const appendDirectoryIndex = parseAppendDirectoryIndex(values['append-directory-index']);
270
+ const opts = {
271
+ ...defaultOptions,
272
+ target,
273
+ transformSyntax,
274
+ rewriteSpecifier: values['rewrite-specifier'] ?? undefined,
275
+ appendJsExtension: appendJsExtension,
276
+ appendDirectoryIndex,
277
+ detectCircularRequires: parseEnum(values['detect-circular-requires'], ['off', 'warn', 'error']) ?? defaultOptions.detectCircularRequires,
278
+ topLevelAwait: parseEnum(values['top-level-await'], ['error', 'wrap', 'preserve']) ?? defaultOptions.topLevelAwait,
279
+ cjsDefault: parseEnum(values['cjs-default'], ['module-exports', 'auto', 'none']) ?? defaultOptions.cjsDefault,
280
+ idiomaticExports: parseEnum(values['idiomatic-exports'], ['off', 'safe', 'aggressive']) ?? defaultOptions.idiomaticExports,
281
+ importMetaPrelude: parseEnum(values['import-meta-prelude'], ['off', 'auto', 'on']) ?? defaultOptions.importMetaPrelude,
282
+ nestedRequireStrategy: parseEnum(values['nested-require-strategy'], ['create-require', 'dynamic-import']) ?? defaultOptions.nestedRequireStrategy,
283
+ requireMainStrategy: parseEnum(values['require-main-strategy'], ['import-meta-main', 'realpath']) ?? defaultOptions.requireMainStrategy,
284
+ liveBindings: parseEnum(values['live-bindings'], ['strict', 'loose', 'off']) ?? defaultOptions.liveBindings,
285
+ cwd: values.cwd ? resolve(String(values.cwd)) : defaultOptions.cwd
286
+ };
287
+ return opts;
288
+ };
289
+ const readStdin = async stdin => {
290
+ const chunks = [];
291
+ for await (const chunk of stdin) {
292
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
293
+ }
294
+ return Buffer.concat(chunks).toString('utf8');
295
+ };
296
+ const expandFiles = async (patterns, cwd, ignore) => {
297
+ const files = new Set();
298
+ for (const pattern of patterns) {
299
+ const matches = await glob(pattern, {
300
+ cwd,
301
+ absolute: true,
302
+ nodir: true,
303
+ windowsPathsNoEscape: true,
304
+ ignore
305
+ });
306
+ for (const m of matches) files.add(resolve(m));
307
+ }
308
+ return [...files];
309
+ };
310
+ const makeLogger = (stdout, stderr) => {
311
+ const enableColor = stdout.isTTY ?? stderr.isTTY ?? false;
312
+ const c = colorize(enableColor);
313
+ const log = (kind, message, stream) => {
314
+ const icon = icons[kind];
315
+ const colored = kind === 'error' ? c.red(message) : kind === 'warn' ? c.yellow(message) : kind === 'success' ? c.green(message) : c.cyan(message);
316
+ stream.write(`${icon} ${colored}\n`);
317
+ };
318
+ return {
319
+ info: msg => log('info', msg, stdout),
320
+ warn: msg => log('warn', msg, stderr),
321
+ error: msg => log('error', msg, stderr),
322
+ success: msg => log('success', msg, stdout),
323
+ color: c
324
+ };
325
+ };
326
+ const applySpecifierUpdates = async (source, filename, opts, appendMode, dirIndex) => {
327
+ if (!opts.rewriteSpecifier && appendMode === 'off' && !dirIndex) return source;
328
+ const lang = getLangFromExt(filename);
329
+ const updated = await specifier.updateSrc(source, lang, spec => {
330
+ const normalized = normalizeBuiltinSpecifier(spec.value);
331
+ const rewritten = rewriteSpecifierValue(normalized ?? spec.value, opts.rewriteSpecifier);
332
+ const baseValue = rewritten ?? normalized ?? spec.value;
333
+ const appended = appendExtensionIfNeeded(baseValue, appendMode, dirIndex);
334
+ return appended ?? rewritten ?? normalized ?? undefined;
335
+ });
336
+ return updated;
337
+ };
338
+ const transformVirtual = async (source, filename, opts) => {
339
+ const ast = parse(filename, source);
340
+ let output = await format(source, ast, {
341
+ ...opts,
342
+ filePath: filename
343
+ });
344
+ const appendMode = opts.appendJsExtension ?? (opts.target === 'module' ? 'relative-only' : 'off');
345
+ const dirIndex = opts.appendDirectoryIndex === undefined ? 'index.js' : opts.appendDirectoryIndex;
346
+ output = await applySpecifierUpdates(output, filename, opts, appendMode, dirIndex);
347
+ return output;
348
+ };
349
+ const summarizeDiagnostics = diags => {
350
+ let warnings = 0;
351
+ let errors = 0;
352
+ for (const d of diags) {
353
+ if (d.level === 'warning') warnings += 1;
354
+ if (d.level === 'error') errors += 1;
355
+ }
356
+ return {
357
+ warnings,
358
+ errors
359
+ };
360
+ };
361
+ const runFiles = async (files, moduleOpts, io, flags) => {
362
+ const results = [];
363
+ const logger = makeLogger(io.stdout, io.stderr);
364
+ for (const file of files) {
365
+ const diagnostics = [];
366
+ const original = await readFile(file, 'utf8');
367
+ const outPath = flags.outDir ? join(flags.outDir, relative(moduleOpts.cwd ?? process.cwd(), file)) : undefined;
368
+ const perFileOpts = {
369
+ ...moduleOpts,
370
+ diagnostics: diag => diagnostics.push(diag),
371
+ out: undefined,
372
+ inPlace: false,
373
+ filePath: file
374
+ };
375
+ let writeTarget;
376
+ if (!flags.dryRun && !flags.list) {
377
+ if (flags.inPlace) {
378
+ perFileOpts.inPlace = true;
379
+ } else if (outPath) {
380
+ writeTarget = outPath;
381
+ perFileOpts.out = outPath;
382
+ await mkdir(dirname(outPath), {
383
+ recursive: true
384
+ });
385
+ } else if (!flags.allowStdout) {
386
+ logger.error('Specify --out-dir or --in-place when transforming files');
387
+ return {
388
+ code: 2,
389
+ results
390
+ };
391
+ }
392
+ }
393
+ const output = await transform(file, perFileOpts);
394
+ const changed = output !== original;
395
+ if (flags.list && changed) {
396
+ logger.info(file);
397
+ }
398
+ if (!flags.dryRun && !flags.list && !writeTarget && !perFileOpts.inPlace) {
399
+ io.stdout.write(output);
400
+ }
401
+ results.push({
402
+ filePath: file,
403
+ changed,
404
+ diagnostics
405
+ });
406
+ const counts = summarizeDiagnostics(diagnostics);
407
+ if (!flags.json) {
408
+ for (const diag of diagnostics) {
409
+ const prefix = diag.level === 'error' ? logger.error : logger.warn;
410
+ const loc = diag.loc ? ` [${diag.loc.start}-${diag.loc.end}]` : '';
411
+ prefix(`${diag.code}: ${diag.message}${loc}`);
412
+ }
413
+ }
414
+ if (counts.errors > 0) {
415
+ return {
416
+ code: 1,
417
+ results
418
+ };
419
+ }
420
+ }
421
+ if (flags.summary && !flags.json) {
422
+ const changedCount = results.filter(r => r.changed).length;
423
+ logger.success(`Processed ${results.length} file(s); changed ${changedCount}`);
424
+ }
425
+ return {
426
+ code: 0,
427
+ results
428
+ };
429
+ };
430
+ const runCli = async ({
431
+ argv = process.argv.slice(2),
432
+ stdin = defaultStdin,
433
+ stdout = defaultStdout,
434
+ stderr = defaultStderr
435
+ } = {}) => {
436
+ const {
437
+ values,
438
+ positionals
439
+ } = parseArgs({
440
+ args: argv,
441
+ allowPositionals: true,
442
+ options: Object.fromEntries(optionsTable.map(opt => [opt.long, {
443
+ type: opt.type,
444
+ short: opt.short
445
+ }]))
446
+ });
447
+ const logger = makeLogger(stdout, stderr);
448
+ if (values.help) {
449
+ stdout.write(buildHelp(stdout.isTTY ?? false));
450
+ return 0;
451
+ }
452
+ if (values.version) {
453
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
454
+ stdout.write(`${pkg.version}\n`);
455
+ return 0;
456
+ }
457
+ const moduleOpts = toModuleOptions(values);
458
+ const cwd = moduleOpts.cwd ?? process.cwd();
459
+ const allowStdout = positionals.length <= 1;
460
+ const fromStdin = positionals.length === 0 || positionals.includes('-');
461
+ const patterns = positionals.filter(p => p !== '-');
462
+ const ignoreValues = values.ignore;
463
+ const ignore = ignoreValues ? (Array.isArray(ignoreValues) ? ignoreValues : [ignoreValues]).map(String) : undefined;
464
+ const outDir = values['out-dir'] ? resolve(cwd, String(values['out-dir'])) : undefined;
465
+ const inPlace = Boolean(values['in-place']);
466
+ const dryRun = Boolean(values['dry-run']);
467
+ const list = Boolean(values.list);
468
+ const summary = Boolean(values.summary);
469
+ const json = Boolean(values.json);
470
+ if (outDir && inPlace) {
471
+ logger.error('Choose either --out-dir or --in-place, not both');
472
+ return 2;
473
+ }
474
+ if (fromStdin && (outDir || inPlace)) {
475
+ logger.error('Cannot combine stdin with --out-dir or --in-place; output goes to stdout');
476
+ return 2;
477
+ }
478
+ const files = await expandFiles(patterns, cwd, ignore);
479
+ if (!fromStdin && files.length === 0) {
480
+ logger.error('No input files were provided or matched');
481
+ return 2;
482
+ }
483
+ const tasks = [];
484
+ if (fromStdin) {
485
+ const virtualName = values['stdin-filename'] ?? 'stdin.js';
486
+ const source = await readStdin(stdin);
487
+ const diagnostics = [];
488
+ const output = await transformVirtual(source, virtualName, {
489
+ ...moduleOpts,
490
+ diagnostics: diag => diagnostics.push(diag),
491
+ filePath: virtualName,
492
+ cwd
493
+ });
494
+ tasks.push({
495
+ filePath: virtualName,
496
+ changed: true,
497
+ diagnostics
498
+ });
499
+ stdout.write(output);
500
+ if (!json) {
501
+ for (const diag of diagnostics) {
502
+ const prefix = diag.level === 'error' ? logger.error : logger.warn;
503
+ const loc = diag.loc ? ` [${diag.loc.start}-${diag.loc.end}]` : '';
504
+ prefix(`${diag.code}: ${diag.message}${loc}`);
505
+ }
506
+ }
507
+ const diagSummary = summarizeDiagnostics(diagnostics);
508
+ if (diagSummary.errors > 0) return 1;
509
+ }
510
+ if (files.length) {
511
+ const result = await runFiles(files, {
512
+ ...moduleOpts,
513
+ cwd
514
+ }, {
515
+ stdout,
516
+ stderr
517
+ }, {
518
+ dryRun,
519
+ list,
520
+ summary,
521
+ json,
522
+ outDir,
523
+ inPlace,
524
+ allowStdout
525
+ });
526
+ if (typeof result.code === 'number' && result.code !== 0) return result.code;
527
+ tasks.push(...result.results);
528
+ }
529
+ if (json) {
530
+ const summaryDiag = summarizeDiagnostics(tasks.flatMap(t => t.diagnostics));
531
+ stdout.write(`${JSON.stringify({
532
+ files: tasks,
533
+ summary: summaryDiag
534
+ }, null, 2)}\n`);
535
+ }
536
+ return 0;
537
+ };
538
+ if (import.meta.main) {
539
+ runCli().then(code => {
540
+ if (code !== 0) process.exit(code);
541
+ }, err => {
542
+ // eslint-disable-next-line no-console -- CLI surface
543
+ console.error(err);
544
+ process.exit(1);
545
+ });
546
+ }
547
+ export { runCli };
package/dist/format.js CHANGED
@@ -573,7 +573,10 @@ const format = async (src, ast, opts) => {
573
573
  seen.add(propName);
574
574
  if (rhs.type === 'Identifier') {
575
575
  const rhsId = rhsSourceFor(rhs);
576
- if (rhsId === rhs.name) {
576
+ const rhsName = rhs.name;
577
+ if (rhsId === rhsName && rhsName === propName) {
578
+ exportsOut.push(`export { ${propName} };`);
579
+ } else if (rhsId === rhsName) {
577
580
  exportsOut.push(`export { ${rhsId} as ${propName} };`);
578
581
  } else {
579
582
  exportsOut.push(`export const ${propName} = ${rhsId};`);
@@ -582,9 +585,15 @@ const format = async (src, ast, opts) => {
582
585
  exportsOut.push(`export const ${propName} = ${rhsSrc};`);
583
586
  }
584
587
  }
588
+
589
+ // Trim trailing whitespace and one optional semicolon so the idiomatic export
590
+ // replacement does not leave the original `;` behind (avoids emitting `;;`).
591
+ let end = write.end;
592
+ while (end < src.length && (src[end] === ' ' || src[end] === '\t')) end++;
593
+ if (end < src.length && src[end] === ';') end++;
585
594
  replacements.push({
586
595
  start: write.start,
587
- end: write.end
596
+ end
588
597
  });
589
598
  }
590
599
  if (!seen.size) return {
@@ -43,6 +43,9 @@ export const metaProperty = (node, parent, src, options) => {
43
43
  case 'main':
44
44
  src.update(parent.start, parent.end, importMetaMainExpr(options.importMetaMain));
45
45
  break;
46
+ default:
47
+ src.update(parent.start, parent.end, `module.${parent.property.name}`);
48
+ break;
46
49
  }
47
50
  }
48
51
  }
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@knighted/module",
3
- "version": "1.2.0",
3
+ "version": "1.3.0-rc.0",
4
4
  "description": "Bidirectional transform for ES modules and CommonJS.",
5
5
  "type": "module",
6
6
  "main": "dist/module.js",
7
+ "bin": {
8
+ "dub": "dist/cli.js"
9
+ },
7
10
  "exports": {
8
11
  ".": {
9
12
  "import": {
@@ -28,7 +31,10 @@
28
31
  "prettier:check": "prettier -c .",
29
32
  "lint": "oxlint --config oxlint.json .",
30
33
  "prepare": "husky",
31
- "test": "c8 --reporter=text --reporter=text-summary --reporter=lcov tsx --test --test-reporter=spec test/*.ts",
34
+ "test:base": "tsx --test --test-reporter=spec",
35
+ "test:cli": "npm run test:base -- test/cli.ts",
36
+ "test:module": "npm run test:base -- test/module.ts",
37
+ "test": "c8 --reporter=text --reporter=text-summary --reporter=lcov npm run test:base -- test/*.ts",
32
38
  "build:types": "tsc --emitDeclarationOnly",
33
39
  "build:dual": "babel-dual-package src --extensions .ts",
34
40
  "build": "npm run build:types && npm run build:dual",
@@ -59,7 +65,7 @@
59
65
  },
60
66
  "devDependencies": {
61
67
  "@knighted/dump": "^1.0.3",
62
- "@types/node": "^22.13.17",
68
+ "@types/node": "^22.19.3",
63
69
  "babel-dual-package": "^1.2.3",
64
70
  "c8": "^10.1.3",
65
71
  "husky": "^9.1.7",
@@ -71,6 +77,7 @@
71
77
  "typescript": "^5.9.3"
72
78
  },
73
79
  "dependencies": {
80
+ "glob": "^13.0.0",
74
81
  "magic-string": "^0.30.21",
75
82
  "oxc-parser": "^0.105.0",
76
83
  "periscopic": "^4.0.2"