ts-node-pack 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-node-pack",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Pack a TypeScript package into a Node-compatible npm tarball without modifying the source tree",
5
5
  "keywords": [
6
6
  "cli",
@@ -26,13 +26,13 @@
26
26
  "exports": {
27
27
  ".": "./src/index.js"
28
28
  },
29
- "engines": {
30
- "node": ">=20"
31
- },
32
- "packageManager": "pnpm@10.33.0",
33
29
  "dependencies": {
34
30
  "npm-packlist": "^10.0.4",
35
31
  "ts-blank-space": "^0.8.0"
36
32
  },
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "packageManager": "pnpm@10.33.0",
37
37
  "types": "./src/index.d.ts"
38
38
  }
package/src/index.d.ts CHANGED
@@ -9,46 +9,6 @@ export interface TsNodePackOptions {
9
9
  * Returns the path to the .tgz file (or the staging dir if --emit-only).
10
10
  */
11
11
  export declare function tsNodePack(packageDir: string, options?: TsNodePackOptions): Promise<string>;
12
- /**
13
- * Rewrite .ts/.tsx extensions to .js in relative import/export specifiers.
14
- *
15
- * Why regex is sound here:
16
- *
17
- * The input is exclusively tsc-generated .d.ts files, not arbitrary source.
18
- * tsc emits a highly constrained subset of syntax in declarations:
19
- *
20
- * - Import/export specifiers are always single-line string literals.
21
- * - Specifiers are always wrapped in matching quotes (' or ").
22
- * - Relative paths always start with ./ or ../ (tsc never normalizes these).
23
- * - No template literals, concatenation, or computed specifiers appear.
24
- * - The only keywords introducing module specifiers are `from`, `import`,
25
- * and `import()`.
26
- *
27
- * Because the grammar of specifiers in .d.ts output is regular (a finite
28
- * set of keyword prefixes + a quoted string literal), regex matches it
29
- * exactly — no ambiguity, no context-sensitivity, no false positives.
30
- *
31
- * If tsc ever adds native .d.ts rewriting (TypeScript#56556), this
32
- * function becomes a no-op: the `if (rewritten !== original)` guard
33
- * in rewriteDtsFiles means no file is touched.
34
- *
35
- * Handles:
36
- * - Named/default/type imports: import { x } from './foo.js'
37
- * - Re-exports: export { x } from './foo.js'
38
- * - Star re-exports: export * from './foo.js'
39
- * - Side-effect imports: import './foo.js'
40
- * - Dynamic imports: import('./foo.js')
41
- * - .tsx extensions: import './component.js'
42
- *
43
- * Does NOT rewrite (by design):
44
- * - Non-relative specifiers: import 'lodash'
45
- * - Already-.js specifiers: import './foo.js'
46
- * - import.meta.url: not a module specifier
47
- * - require(): not valid in ESM .d.ts
48
- * - Bare string literals: const x = './foo.ts'
49
- */
50
- export declare const TS_SPECIFIER_PATTERNS: RegExp[];
51
- export declare function rewriteTsSpecifiers(content: any): any;
52
12
  /**
53
13
  * Resolve `workspace:` protocol specifiers in the package's dependency
54
14
  * fields to concrete versions, matching yarn-berry and pnpm behavior.
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ import { basename, dirname, join, relative, resolve } from "node:path";
15
15
  import { promisify } from "node:util";
16
16
  import packlist from "npm-packlist";
17
17
  import tsBlankSpace from "ts-blank-space";
18
+ import { TS_SPECIFIER_PATTERNS, rewriteTsSpecifiers } from "./rewrite-specifiers.js";
18
19
 
19
20
  const execFileAsync = promisify(execFile);
20
21
 
@@ -45,7 +46,7 @@ export async function tsNodePack(
45
46
  log(`Package: ${pkgJson.name}@${pkgJson.version}`);
46
47
 
47
48
  const tsconfigPath = resolveTsconfig(packageDir, tsconfig);
48
- log(`Using tsconfig: ${tsconfigPath}`);
49
+ if (tsconfigPath) log(`Found tsconfig: ${tsconfigPath}`);
49
50
 
50
51
  // ── Phase 2: Create staging directory ─────────────────────────────────
51
52
  log("Phase 2: Creating staging directory...");
@@ -55,99 +56,106 @@ export async function tsNodePack(
55
56
  log(`Staging: ${stagingDir}`);
56
57
 
57
58
  try {
58
- // ── Phase 3: Generate derived tsconfig ────────────────────────────────
59
- // Written inside the temp dir — never touches the source tree.
60
- // `extends` takes an absolute path; paths defined by the base tsconfig
61
- // (include/files/exclude/rootDir/paths) resolve relative to the base
62
- // config's own location, which is still inside packageDir, so globs
63
- // continue to work.
64
- log("Phase 3: Generating derived tsconfig...");
65
- const emitConfigPath = join(tmpDir, "tsconfig.emit.json");
66
-
67
- // Because the emit config lives outside packageDir, tsc's typeRoots
68
- // walk-up (which starts at the config's directory) can't reach
69
- // packageDir/node_modules/@types. Pin typeRoots explicitly when that
70
- // directory exists so entries named in the base config's `types`
71
- // field — e.g. `types: ["node"]` — can be resolved. TS 6.0 made this
72
- // explicit opt-in (no more auto-inclusion of every @types package),
73
- // which surfaces the missing resolution as a clear TS2688 error
74
- // rather than silent "Cannot find module 'node:util'".
75
- const atTypesDir = join(packageDir, "node_modules", "@types");
76
- const hasAtTypes = existsSync(atTypesDir);
77
-
78
- const emitConfig = {
79
- extends: tsconfigPath,
80
- compilerOptions: {
81
- // Force rootDir so emit preserves the source layout — otherwise
82
- // tsc infers the common ancestor and strips the `src/` prefix,
83
- // breaking `main`/`exports` that reference `./src/...`.
84
- rootDir: packageDir,
85
- outDir: stagingDir,
86
- declaration: true,
87
- // ts-blank-space handles .js emit (Phase 6); tsc only emits .d.ts.
88
- emitDeclarationOnly: true,
89
- // Extract types from JS+JSDoc sources in mixed packages.
90
- allowJs: true,
91
- noEmit: false,
92
- // Incremental/composite would try to write a .tsbuildinfo whose
93
- // path tsc computes relative to the base config, producing
94
- // garbled paths when our outDir crosses directory trees.
95
- incremental: false,
96
- composite: false,
97
- tsBuildInfoFile: null,
98
- ...(hasAtTypes ? { typeRoots: [atTypesDir] } : {}),
99
- },
100
- };
101
- await writeFile(emitConfigPath, JSON.stringify(emitConfig, null, 2) + "\n");
102
-
103
- // ── Phase 4: Copy the npm-packlist into staging ──────────────────────
59
+ // ── Phase 3: Copy the npm-packlist into staging ──────────────────────
104
60
  // `npm-packlist` is the same file-enumeration engine `npm pack` uses
105
61
  // internally. It applies `files`, `.npmignore`, the npm default
106
62
  // includes (package.json, README*, LICENSE*) and default excludes
107
63
  // (.git, node_modules, ...) — so the staging directory ends up as a
108
64
  // faithful mirror of whatever `npm pack` would have produced from
109
- // the source tree, minus our downstream transforms (ts-blank-space,
110
- // declaration emit, specifier rewrite).
111
- log("Phase 4: Enumerating package files via npm-packlist...");
112
- // npm-packlist's tree-based API (v7+) expects a node with `path` +
113
- // parsed `package.json`. We fabricate a minimal tree rather than
114
- // pulling in @npmcli/arborist — we don't need bundled-dependency
115
- // resolution, workspace walking, or the rest of arborist's
116
- // machinery, just the pack-file list.
65
+ // the source tree, minus our downstream transforms.
117
66
  //
118
- // `isProjectRoot: true` sends npm-packlist down the
119
- // "root package" branch of `gatherBundles`, which looks only at
120
- // bundleDependencies (missing here, so no-op). Without this, it
121
- // takes the "bundled dep of some other project" branch and tries
122
- // to walk `edgesOut` — which doesn't exist on our hand-built tree.
67
+ // `isProjectRoot: true` sends npm-packlist down its "root package"
68
+ // branch without it, it tries to walk `edgesOut` which doesn't
69
+ // exist on our hand-built tree (no @npmcli/arborist).
70
+ log("Phase 3: Enumerating package files via npm-packlist...");
123
71
  const packFiles = await packlist({
124
72
  path: packageDir,
125
73
  package: pkgJson,
126
74
  isProjectRoot: true,
127
75
  });
128
- // Pre-create every parent directory serially (mkdir on the same
129
- // path from multiple concurrent calls can race), then copy files
130
- // in parallel.
131
- const dirs = new Set (
132
- packFiles.map((rel) => dirname(join(stagingDir, rel))),
133
- );
76
+ // Pre-create unique parent dirs serially (concurrent mkdir on the
77
+ // same path can race on some filesystems), then copy in parallel.
78
+ const dirs = new Set (packFiles.map((rel) => dirname(join(stagingDir, rel))));
134
79
  for (const d of dirs) await mkdir(d, { recursive: true });
135
80
  await Promise.all(
136
- packFiles.map((rel) =>
137
- copyFile(join(packageDir, rel), join(stagingDir, rel)),
138
- ),
81
+ packFiles.map((rel) => copyFile(join(packageDir, rel), join(stagingDir, rel))),
139
82
  );
140
83
  log(`Copied ${packFiles.length} file(s) into staging`);
141
84
 
142
- // ── Phase 5: Emit declarations via tsc ──────────────────────────────
143
- // tsc reads sources from packageDir (the base config's include
144
- // resolves there) and writes .d.ts files into stagingDir via outDir.
145
- // The copies from Phase 4 are left alone — tsc's outputs land
146
- // alongside them. tsc may emit declarations for files outside the
147
- // `files` array (e.g. test/); those won't make it into the final
148
- // tarball because npm pack only ships what `files` references.
149
- log("Phase 5: Emitting .d.ts files via tsc...");
150
- await runTsc(emitConfigPath, packageDir, log);
85
+ // ── Phase 4: Decide whether to run tsc, generate config if so ────────
86
+ // Trigger declaration emit only when there's something to derive:
87
+ // 1. user passed --tsconfig (explicit opt-in), OR
88
+ // 2. any source contains .ts/.tsx/.mts (we'd be stripping them
89
+ // anyway, and probably want their declarations).
90
+ // The mere presence of tsconfig.build.json is NOT enough monorepos
91
+ // commonly keep one per package for a root project-references build
92
+ // (`tsc --build`) without intending each package to be independently
93
+ // emit-able. Pure JS+JSDoc packages with no .ts sources skip tsc and
94
+ // ship whatever .js files are already in the packlist, matching plain
95
+ // `npm pack` semantics.
96
+ const hasTsSources = packFiles.some(
97
+ (f) => /\.(ts|tsx|mts)$/.test(f) && !/\.d\.(ts|mts)$/.test(f),
98
+ );
99
+ const shouldRunTsc = tsconfigPath !== null && (tsconfig !== undefined || hasTsSources);
100
+
101
+ if (shouldRunTsc) {
102
+ log("Phase 4: Generating derived tsconfig...");
103
+ const emitConfigPath = join(tmpDir, "tsconfig.emit.json");
104
+ // tsc's typeRoots walk-up starts at the config's directory, so it
105
+ // can't reach packageDir/node_modules/@types from our temp-dir
106
+ // location. Pin typeRoots when that directory exists. (TS 6.0
107
+ // surfaces the missing resolution as TS2688 instead of the silent
108
+ // "Cannot find module 'node:util'" cascade of earlier versions.)
109
+ // Walk upward to find the nearest ancestor that actually has
110
+ // node_modules/@types. Yarn 4's pnpm linker keeps `@types` only at
111
+ // the workspace root, so the package's own node_modules is empty.
112
+ const findAtTypes = (start ) => {
113
+ let dir = start;
114
+ while (true) {
115
+ const candidate = join(dir, "node_modules", "@types");
116
+ if (existsSync(candidate)) return candidate;
117
+ const parent = dirname(dir);
118
+ if (parent === dir) return null;
119
+ dir = parent;
120
+ }
121
+ };
122
+ const atTypesDir = findAtTypes(packageDir);
123
+ const hasAtTypes = atTypesDir !== null;
124
+ const emitConfig = {
125
+ extends: tsconfigPath,
126
+ compilerOptions: {
127
+ // Force rootDir so emit preserves the source layout —
128
+ // otherwise tsc infers the common ancestor and strips the
129
+ // `src/` prefix, breaking `main`/`exports` that reference
130
+ // `./src/...`.
131
+ rootDir: packageDir,
132
+ outDir: stagingDir,
133
+ declaration: true,
134
+ // ts-blank-space handles .js emit (Phase 6); tsc only emits .d.ts.
135
+ emitDeclarationOnly: true,
136
+ // Extract types from JS+JSDoc sources in mixed packages.
137
+ allowJs: true,
138
+ noEmit: false,
139
+ // Incremental/composite write a .tsbuildinfo whose path tsc
140
+ // computes relative to the base config, producing garbled
141
+ // paths when our outDir crosses directory trees.
142
+ incremental: false,
143
+ composite: false,
144
+ tsBuildInfoFile: null,
145
+ ...(hasAtTypes ? { typeRoots: [atTypesDir] } : {}),
146
+ },
147
+ };
148
+ await writeFile(emitConfigPath, JSON.stringify(emitConfig, null, 2) + "\n");
149
+
150
+ log("Phase 5: Emitting .d.ts files via tsc...");
151
+ await runTsc(emitConfigPath, packageDir, log);
152
+ } else {
153
+ log(
154
+ tsconfigPath === null
155
+ ? "Phase 4-5: Skipping tsc (no tsconfig found; pure-JS package)"
156
+ : "Phase 4-5: Skipping tsc (no .ts sources and no tsconfig.build.json opt-in)",
157
+ );
158
+ }
151
159
 
152
160
  // ── Phase 6: Strip types and rewrite specifiers ─────────────────────
153
161
  // Single-pass transform of every staging file that could contain a
@@ -157,10 +165,7 @@ export async function tsNodePack(
157
165
  // ts-blank-space preserves line and column positions, so no
158
166
  // sourcemaps are needed for debugging.
159
167
  log("Phase 6: Stripping types and rewriting specifiers...");
160
- const { strippedCount, rewrittenCount } = await processStagingFiles(
161
- stagingDir,
162
- log,
163
- );
168
+ const { strippedCount, rewrittenCount } = await processStagingFiles(stagingDir, log);
164
169
  log(
165
170
  `Stripped ${strippedCount} type-annotated file(s); rewrote ${rewrittenCount} other file(s)`,
166
171
  );
@@ -173,16 +178,9 @@ export async function tsNodePack(
173
178
  // packages to publish), then flip entry paths and strip dev-only
174
179
  // fields.
175
180
  log("Phase 7: Rewriting package.json...");
176
- const resolvedPkg = await resolveWorkspaceDependencies(
177
- pkgJson,
178
- packageDir,
179
- log,
180
- );
181
+ const resolvedPkg = await resolveWorkspaceDependencies(pkgJson, packageDir, log);
181
182
  const rewrittenPkg = rewritePackageJson(resolvedPkg);
182
- await writeFile(
183
- join(stagingDir, "package.json"),
184
- JSON.stringify(rewrittenPkg, null, 2) + "\n",
185
- );
183
+ await writeFile(join(stagingDir, "package.json"), JSON.stringify(rewrittenPkg, null, 2) + "\n");
186
184
 
187
185
  // ── Phase 8: Validate ─────────────────────────────────────────────────
188
186
  log("Phase 8: Validating...");
@@ -191,7 +189,7 @@ export async function tsNodePack(
191
189
  // ── Phase 9: Pack ─────────────────────────────────────────────────────
192
190
  if (!emitOnly) {
193
191
  log("Phase 9: Packing...");
194
- const tgzPath = await pack(stagingDir, log);
192
+ const tgzPath = await pack(stagingDir);
195
193
  const tgzName = basename(tgzPath);
196
194
  const dest = join(process.cwd(), tgzName);
197
195
  await copyFile(tgzPath, dest);
@@ -214,7 +212,12 @@ export async function tsNodePack(
214
212
 
215
213
  // ── Phase helpers ──────────────────────────────────────────────────────────
216
214
 
217
- function resolveTsconfig(packageDir, tsconfigOption) {
215
+ /**
216
+ * Find the tsconfig to extend, or null if the package has no TypeScript
217
+ * config at all (pure JS package). The `--tsconfig` flag is the one
218
+ * explicit case where missing-config is fatal.
219
+ */
220
+ function resolveTsconfig(packageDir, tsconfigOption) {
218
221
  if (tsconfigOption) {
219
222
  const p = resolve(packageDir, tsconfigOption);
220
223
  if (!existsSync(p)) {
@@ -222,14 +225,11 @@ function resolveTsconfig(packageDir, tsconfigOption) {
222
225
  }
223
226
  return p;
224
227
  }
225
-
226
228
  const buildConfig = join(packageDir, "tsconfig.build.json");
227
229
  if (existsSync(buildConfig)) return buildConfig;
228
-
229
230
  const defaultConfig = join(packageDir, "tsconfig.json");
230
231
  if (existsSync(defaultConfig)) return defaultConfig;
231
-
232
- throw new Error(`No tsconfig found in ${packageDir}. Provide one with --tsconfig.`);
232
+ return null;
233
233
  }
234
234
 
235
235
  /**
@@ -248,14 +248,33 @@ function findLocalBin(startDir, name) {
248
248
  }
249
249
  }
250
250
 
251
- async function runTsc(emitConfigPath, cwd, log) {
251
+ async function findLocalTsc(cwd) {
252
252
  // Prefer a local tsc so users control the compiler version (and to avoid
253
253
  // npx resolving to macOS's /usr/bin/tsc — the TeX/Smalltalk compiler —
254
254
  // when no local install exists). In a monorepo, the package's own
255
255
  // node_modules/.bin may be empty while the workspace root has the
256
256
  // binary, so walk upward the same way npm's `$PATH` composition does.
257
257
  const binName = process.platform === "win32" ? "tsc.cmd" : "tsc";
258
- const localTsc = findLocalBin(cwd, binName);
258
+ const fromBin = findLocalBin(cwd, binName);
259
+ if (fromBin !== null) return fromBin;
260
+ // Yarn 4's pnpm/PnP linkers do not populate node_modules/.bin/. Fall back
261
+ // to `yarn bin tsc`, which resolves through the active linker and prints
262
+ // the absolute path of the tsc binary when the workspace depends on it.
263
+ try {
264
+ const { stdout } = await execFileAsync("yarn", ["bin", "tsc"], {
265
+ cwd,
266
+ maxBuffer: 1024 * 1024,
267
+ });
268
+ const candidate = stdout.trim().split("\n").pop();
269
+ if (candidate && existsSync(candidate)) return candidate;
270
+ } catch {
271
+ // `yarn` not on PATH or not a yarn project — fall through to npx.
272
+ }
273
+ return null;
274
+ }
275
+
276
+ async function runTsc(emitConfigPath, cwd, log) {
277
+ const localTsc = await findLocalTsc(cwd);
259
278
  const useLocal = localTsc !== null;
260
279
  const [cmd, argv] = useLocal
261
280
  ? [localTsc, ["-p", emitConfigPath]]
@@ -314,18 +333,14 @@ async function processStagingFiles(stagingDir, log) {
314
333
  });
315
334
  if (errors.length > 0) {
316
335
  throw new Error(
317
- "ts-blank-space rejected non-erasable TypeScript:\n " +
318
- errors.join("\n "),
336
+ "ts-blank-space rejected non-erasable TypeScript:\n " + errors.join("\n "),
319
337
  );
320
338
  }
321
339
  }
322
340
  content = rewriteTsSpecifiers(content);
323
341
 
324
342
  if (isTs || isMts) {
325
- const destPath = srcPath.replace(
326
- isMts ? /\.mts$/ : /\.ts$/,
327
- isMts ? ".mjs" : ".js",
328
- );
343
+ const destPath = srcPath.replace(isMts ? /\.mts$/ : /\.ts$/, isMts ? ".mjs" : ".js");
329
344
  await writeFile(destPath, content);
330
345
  await unlink(srcPath);
331
346
  strippedCount++;
@@ -339,70 +354,6 @@ async function processStagingFiles(stagingDir, log) {
339
354
  return { strippedCount, rewrittenCount };
340
355
  }
341
356
 
342
- /**
343
- * Rewrite .ts/.tsx extensions to .js in relative import/export specifiers.
344
- *
345
- * Why regex is sound here:
346
- *
347
- * The input is exclusively tsc-generated .d.ts files, not arbitrary source.
348
- * tsc emits a highly constrained subset of syntax in declarations:
349
- *
350
- * - Import/export specifiers are always single-line string literals.
351
- * - Specifiers are always wrapped in matching quotes (' or ").
352
- * - Relative paths always start with ./ or ../ (tsc never normalizes these).
353
- * - No template literals, concatenation, or computed specifiers appear.
354
- * - The only keywords introducing module specifiers are `from`, `import`,
355
- * and `import()`.
356
- *
357
- * Because the grammar of specifiers in .d.ts output is regular (a finite
358
- * set of keyword prefixes + a quoted string literal), regex matches it
359
- * exactly — no ambiguity, no context-sensitivity, no false positives.
360
- *
361
- * If tsc ever adds native .d.ts rewriting (TypeScript#56556), this
362
- * function becomes a no-op: the `if (rewritten !== original)` guard
363
- * in rewriteDtsFiles means no file is touched.
364
- *
365
- * Handles:
366
- * - Named/default/type imports: import { x } from './foo.js'
367
- * - Re-exports: export { x } from './foo.js'
368
- * - Star re-exports: export * from './foo.js'
369
- * - Side-effect imports: import './foo.js'
370
- * - Dynamic imports: import('./foo.js')
371
- * - .tsx extensions: import './component.js'
372
- *
373
- * Does NOT rewrite (by design):
374
- * - Non-relative specifiers: import 'lodash'
375
- * - Already-.js specifiers: import './foo.js'
376
- * - import.meta.url: not a module specifier
377
- * - require(): not valid in ESM .d.ts
378
- * - Bare string literals: const x = './foo.ts'
379
- */
380
- // Three shapes for a relative TypeScript specifier in a string literal:
381
- // 1. `from './foo.js'` — named/default/type imports, re-exports
382
- // 2. `import './foo.js'` — bare side-effect imports
383
- // 3. `import('./foo.js')` — dynamic imports
384
- //
385
- // The extension capture group matches `ts`, `tsx`, or `mts`. Rewriting
386
- // turns `.ts`/`.tsx` into `.js` and `.mts` into `.mjs`, matching the
387
- // output extensions ts-blank-space produces in Phase 6.
388
- //
389
- // Used both to rewrite specifiers and (in the validator) to detect any
390
- // specifier that slipped through. Must stay in sync with each other.
391
- export const TS_SPECIFIER_PATTERNS = [
392
- /(from\s*['"])(\.\.?\/[^'"]*?)\.(tsx?|mts)(['"])/g,
393
- /(import\s+['"])(\.\.?\/[^'"]*?)\.(tsx?|mts)(['"])/g,
394
- /(import\s*\(\s*['"])(\.\.?\/[^'"]*?)\.(tsx?|mts)(['"]\s*\))/g,
395
- ];
396
-
397
- export function rewriteTsSpecifiers(content) {
398
- for (const pattern of TS_SPECIFIER_PATTERNS) {
399
- content = content.replace(pattern, (_m, pre, path, ext, post) =>
400
- pre + path + (ext === "mts" ? ".mjs" : ".js") + post,
401
- );
402
- }
403
- return content;
404
- }
405
-
406
357
  const DEP_FIELDS_WITH_WORKSPACE = [
407
358
  "dependencies",
408
359
  "peerDependencies",
@@ -481,9 +432,7 @@ async function findWorkspaceRoot(startDir) {
481
432
  let dir = startDir;
482
433
  while (true) {
483
434
  try {
484
- const pkg = JSON.parse(
485
- await readFile(join(dir, "package.json"), "utf8"),
486
- );
435
+ const pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
487
436
  if (pkg.workspaces) return dir;
488
437
  } catch {
489
438
  // Missing or unreadable package.json — not the root, keep going.
@@ -495,9 +444,7 @@ async function findWorkspaceRoot(startDir) {
495
444
  }
496
445
 
497
446
  async function buildWorkspaceVersionMap(workspaceRoot) {
498
- const rootPkg = JSON.parse(
499
- await readFile(join(workspaceRoot, "package.json"), "utf8"),
500
- );
447
+ const rootPkg = JSON.parse(await readFile(join(workspaceRoot, "package.json"), "utf8"));
501
448
  const patterns = Array.isArray(rootPkg.workspaces)
502
449
  ? rootPkg.workspaces
503
450
  : rootPkg.workspaces?.packages || [];
@@ -507,9 +454,7 @@ async function buildWorkspaceVersionMap(workspaceRoot) {
507
454
  const dirs = await expandWorkspacePattern(workspaceRoot, pattern);
508
455
  for (const d of dirs) {
509
456
  try {
510
- const pkg = JSON.parse(
511
- await readFile(join(d, "package.json"), "utf8"),
512
- );
457
+ const pkg = JSON.parse(await readFile(join(d, "package.json"), "utf8"));
513
458
  if (pkg.name && pkg.version) map.set(pkg.name, pkg.version);
514
459
  } catch {
515
460
  // Missing or malformed sibling package.json — skip.
@@ -546,9 +491,7 @@ async function expandWorkspacePattern(workspaceRoot, pattern) {
546
491
  }
547
492
  } else if (part.includes("*")) {
548
493
  const regex = new RegExp(
549
- "^" +
550
- part.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") +
551
- "$",
494
+ "^" + part.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
552
495
  );
553
496
  try {
554
497
  const entries = await readdir(base, { withFileTypes: true });
@@ -673,9 +616,7 @@ async function validate(stagingDir, pkg, log) {
673
616
  // false positives. Comment stripping here is intentionally coarse
674
617
  // (doesn't understand strings-containing-comment-markers) — good enough
675
618
  // for tsc-emitted output, where comments are well-behaved.
676
- const content = raw
677
- .replace(/\/\*[\s\S]*?\*\//g, "")
678
- .replace(/(^|[^:])\/\/.*$/gm, "$1");
619
+ const content = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
679
620
  const relPath = relative(stagingDir, filePath);
680
621
 
681
622
  for (const pattern of TS_SPECIFIER_PATTERNS) {
@@ -751,7 +692,7 @@ function collectExportsRefs(value, refs) {
751
692
  }
752
693
  }
753
694
 
754
- async function pack(stagingDir, log) {
695
+ async function pack(stagingDir) {
755
696
  const { stdout } = await execFileAsync("npm", ["pack"], {
756
697
  cwd: stagingDir,
757
698
  });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @file Rewrite relative TypeScript specifiers to JavaScript ones.
3
+ *
4
+ * The input grammar is intentionally narrow: a relative path (`./` or
5
+ * `../`) ending in `.ts`, `.tsx`, or `.mts`, embedded in one of six
6
+ * specific syntactic contexts. The output replaces the extension with
7
+ * `.js` (for `.ts`/`.tsx`) or `.mjs` (for `.mts`), matching what
8
+ * `ts-blank-space` produces in the strip phase.
9
+ *
10
+ * Why regex is sound here:
11
+ *
12
+ * The inputs are tsc-emitted `.d.ts` declaration files and JS sources
13
+ * stripped of types — both highly constrained subsets of JavaScript:
14
+ *
15
+ * - Import/export specifiers are always single-line string literals.
16
+ * - Specifiers are always wrapped in matching quotes (' or ").
17
+ * - Relative paths always start with `./` or `../`.
18
+ * - No template literals, concatenation, or computed specifiers appear.
19
+ *
20
+ * Because the grammar of relative specifiers in this input is regular
21
+ * (a finite set of keyword prefixes + a quoted string literal), regex
22
+ * matches it exactly with no false positives — provided the patterns
23
+ * below stay in sync with each other and the validator that uses them.
24
+ */
25
+ export declare const TS_SPECIFIER_PATTERNS: RegExp[];
26
+ /**
27
+ * Apply every pattern in TS_SPECIFIER_PATTERNS to `content`, rewriting
28
+ * each matched relative TypeScript specifier to its corresponding
29
+ * JavaScript form. Returns the transformed string; the input is not
30
+ * mutated.
31
+ */
32
+ export declare function rewriteTsSpecifiers(content: string): string;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @file Rewrite relative TypeScript specifiers to JavaScript ones.
3
+ *
4
+ * The input grammar is intentionally narrow: a relative path (`./` or
5
+ * `../`) ending in `.ts`, `.tsx`, or `.mts`, embedded in one of six
6
+ * specific syntactic contexts. The output replaces the extension with
7
+ * `.js` (for `.ts`/`.tsx`) or `.mjs` (for `.mts`), matching what
8
+ * `ts-blank-space` produces in the strip phase.
9
+ *
10
+ * Why regex is sound here:
11
+ *
12
+ * The inputs are tsc-emitted `.d.ts` declaration files and JS sources
13
+ * stripped of types — both highly constrained subsets of JavaScript:
14
+ *
15
+ * - Import/export specifiers are always single-line string literals.
16
+ * - Specifiers are always wrapped in matching quotes (' or ").
17
+ * - Relative paths always start with `./` or `../`.
18
+ * - No template literals, concatenation, or computed specifiers appear.
19
+ *
20
+ * Because the grammar of relative specifiers in this input is regular
21
+ * (a finite set of keyword prefixes + a quoted string literal), regex
22
+ * matches it exactly with no false positives — provided the patterns
23
+ * below stay in sync with each other and the validator that uses them.
24
+ */
25
+
26
+ // Six shapes for a relative TypeScript specifier in a string literal:
27
+ // 1. `from './foo.js'` — named/default/type imports, re-exports
28
+ // 2. `import './foo.js'` — bare side-effect imports
29
+ // 3. `import('./foo.js')` — dynamic imports
30
+ // 4. `require('./foo.js')` — CJS requires (in .cjs / .d.cts)
31
+ // 5. `declare module './foo.js' {…}` — TypeScript ambient declarations
32
+ // 6. `/// <reference path="./foo.js"/>` — triple-slash directives
33
+ //
34
+ // The extension capture group matches `ts`, `tsx`, or `mts`. The
35
+ // negative lookbehind `(?<!\.d)` prevents the regex from matching
36
+ // `./foo.d.ts` (and `.d.mts`/`.d.cts`/`.d.tsx`), which are pure
37
+ // declaration files that must keep their original specifiers.
38
+ //
39
+ // Word-boundary anchors (`\b`) on the keyword-leading patterns prevent
40
+ // false positives on identifiers like `myfrom` or `customRequire`.
41
+ //
42
+ // Used both to rewrite specifiers and (in the validator) to detect any
43
+ // specifier that slipped through. Patterns must stay in sync.
44
+ export const TS_SPECIFIER_PATTERNS = [
45
+ /(\bfrom\s*['"])(\.\.?\/[^'"]*?)(?<!\.d)\.(tsx?|mts)(['"])/g,
46
+ /(\bimport\s+['"])(\.\.?\/[^'"]*?)(?<!\.d)\.(tsx?|mts)(['"])/g,
47
+ /(\bimport\s*\(\s*['"])(\.\.?\/[^'"]*?)(?<!\.d)\.(tsx?|mts)(['"]\s*\))/g,
48
+ /(\brequire\s*\(\s*['"])(\.\.?\/[^'"]*?)(?<!\.d)\.(tsx?|mts)(['"]\s*\))/g,
49
+ /(\bdeclare\s+module\s*['"])(\.\.?\/[^'"]*?)(?<!\.d)\.(tsx?|mts)(['"])/g,
50
+ /(<reference\s+path\s*=\s*['"])(\.\.?\/[^'"]*?)(?<!\.d)\.(tsx?|mts)(['"])/g,
51
+ ];
52
+
53
+ /**
54
+ * Apply every pattern in TS_SPECIFIER_PATTERNS to `content`, rewriting
55
+ * each matched relative TypeScript specifier to its corresponding
56
+ * JavaScript form. Returns the transformed string; the input is not
57
+ * mutated.
58
+ */
59
+ export function rewriteTsSpecifiers(content ) {
60
+ for (const pattern of TS_SPECIFIER_PATTERNS) {
61
+ content = content.replace(
62
+ pattern,
63
+ (_m, pre, path, ext, post) => pre + path + (ext === "mts" ? ".mjs" : ".js") + post,
64
+ );
65
+ }
66
+ return content;
67
+ }