From c89f86c12da7f182749f04fb5f5046aeb51920ae Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Sat, 25 Apr 2026 10:34:31 +0800 Subject: [PATCH 1/7] fix(types): rewrite .d.mts relative specifiers to .mjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `emitDualDeclarations` was producing `.d.mts` files whose relative specifiers ended in `.js`. Under TS's node16+ ESM resolver, a `.js` specifier in a `.d.mts` is paired with the adjacent `.d.ts`, which — in a dual-published package whose root `package.json` has `"type": "commonjs"` — is treated as CJS-flavoured. Strict ESM consumers then surface type-resolution errors against an otherwise valid package. `rewriteSpecifier` is only ever called for the `.d.mts` branch, so the extension it emits should be `.mjs`, which pairs with the `.d.mts` twin and keeps the resolution chain in ESM throughout. The `.d.cts` branch is unaffected — those remain content-identical copies of the source `.d.ts`, which CJS resolution handles via extensionless specifiers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../copy-package-json-from-config.spec.ts | 10 ++++---- src/util/copy-package-json-from-config.ts | 24 +++++++++++-------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/util/copy-package-json-from-config.spec.ts b/src/util/copy-package-json-from-config.spec.ts index 6139bd3..bcb1189 100644 --- a/src/util/copy-package-json-from-config.spec.ts +++ b/src/util/copy-package-json-from-config.spec.ts @@ -35,10 +35,10 @@ describe('rewriteEsmRelativeImports', () => { expect(rewriteEsmRelativeImports(input, dir)).toBe(input) }) - it('Resolves directory specifiers to /index.js', () => { + it('Resolves directory specifiers to /index.mjs', () => { fs.mkdirSync(path.join(dir, 'sub')) fs.writeFileSync(path.join(dir, 'sub', 'index.d.ts'), '', 'utf-8') - expect(rewriteEsmRelativeImports(`from './sub'`, dir)).toBe(`from './sub/index.js'`) + expect(rewriteEsmRelativeImports(`from './sub'`, dir)).toBe(`from './sub/index.mjs'`) }) it('Leaves specifiers that cannot be resolved alone', () => { @@ -190,14 +190,14 @@ describe('copyPackageJsonFromConfig', () => { }) const mtsContents = fs.readFileSync(path.join(outDir, 'index.d.mts'), 'utf-8') - expect(mtsContents).toContain(`from './util/helper.js'`) - expect(mtsContents).toContain(`import('./util/helper.js')`) + expect(mtsContents).toContain(`from './util/helper.mjs'`) + expect(mtsContents).toContain(`import('./util/helper.mjs')`) expect(mtsContents).not.toContain(`from './util/helper'`) // The .d.cts should be content-identical to the original .d.ts const ctsContents = fs.readFileSync(path.join(outDir, 'index.d.cts'), 'utf-8') expect(ctsContents).toContain(`from './util/helper'`) - expect(ctsContents).not.toContain(`from './util/helper.js'`) + expect(ctsContents).not.toContain(`from './util/helper.mjs'`) }) it('Does not emit dual declarations in single-flavor modes', () => { diff --git a/src/util/copy-package-json-from-config.ts b/src/util/copy-package-json-from-config.ts index 1741d57..4ffa04b 100644 --- a/src/util/copy-package-json-from-config.ts +++ b/src/util/copy-package-json-from-config.ts @@ -87,9 +87,10 @@ function buildExportEntry(value: string, exportTypes: ExportType) { // Produces .d.mts and .d.cts siblings for every .d.ts in outDir so ESM and // CJS consumers each resolve types in their own module system. The .d.mts copy -// has its extensionless relative imports rewritten to `.js` — TS's node16+ ESM -// resolution requires explicit extensions on relative specifiers. The .d.cts -// copy can be content-identical since CJS resolution tolerates either form. +// has its extensionless relative imports rewritten to `.mjs` so that TS's +// node16+ ESM resolution pairs each declaration with its .d.mts twin rather +// than the CJS-flavoured .d.ts. The .d.cts copy can be content-identical +// since CJS resolution tolerates extensionless specifiers. function emitDualDeclarations(outDir: string) { if (!fs.existsSync(outDir)) return let emitted = 0 @@ -118,11 +119,14 @@ function emitIfMissing(destination: string, produceContent: () => string): numbe } // Rewrites relative specifiers in a declaration file for ESM resolution: -// from './x' → from './x.js' (when ./x.d.ts exists) -// from './x' → from './x/index.js' (when ./x/index.d.ts exists) -// Non-relative specifiers, already-extensioned specifiers, and unresolvable -// paths are left alone. Covers `from '...'`, bare `import '...'`, and -// dynamic `import('...')` forms — the shapes that appear in .d.ts output. +// from './x' → from './x.mjs' (when ./x.d.ts exists) +// from './x' → from './x/index.mjs' (when ./x/index.d.ts exists) +// The .mjs extension pairs the specifier with the adjacent .d.mts declaration +// under TS's node16+ resolver; using .js would resolve against the .d.ts +// (CJS-flavoured in a dual-published package) and surface as type errors in +// strict ESM consumers. Non-relative specifiers, already-extensioned +// specifiers, and unresolvable paths are left alone. Covers `from '...'`, +// bare `import '...'`, and dynamic `import('...')` forms. export function rewriteEsmRelativeImports(source: string, sourceDir: string): string { const patterns = [ /(\bfrom\s*)(['"])(\.{1,2}\/[^'"]+)\2/g, @@ -142,9 +146,9 @@ export function rewriteEsmRelativeImports(source: string, sourceDir: string): st function rewriteSpecifier(spec: string, sourceDir: string): string | null { if (/\.(m?js|cjs|json|node|d\.m?ts|d\.cts|tsx?|jsx?)$/i.test(spec)) return null const candidate = path.resolve(sourceDir, spec) - if (fs.existsSync(`${candidate}.d.ts`) || fs.existsSync(`${candidate}.d.mts`)) return `${spec}.js` + if (fs.existsSync(`${candidate}.d.ts`) || fs.existsSync(`${candidate}.d.mts`)) return `${spec}.mjs` if (fs.existsSync(path.join(candidate, 'index.d.ts')) || fs.existsSync(path.join(candidate, 'index.d.mts'))) { - return spec.endsWith('/') ? `${spec}index.js` : `${spec}/index.js` + return spec.endsWith('/') ? `${spec}index.mjs` : `${spec}/index.mjs` } return null } From 8cd21ba56c6626ea72858c29eb4b687ddbfba8e1 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Sat, 25 Apr 2026 10:45:20 +0800 Subject: [PATCH 2/7] test+docs: cover .mjs rewrite cases and document dual-decl emission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests - Add focused unit test for the file case (was only exercised via the integration test): `from './foo'` → `from './foo.mjs'` when `./foo.d.ts` exists. - Add test for resolving when only the `.d.mts` twin is present alongside the source, exercising the second branch of `rewriteSpecifier`. - Add test that runs all three module-specifier shapes through one pass (`from '...'`, bare `import '...'`, dynamic `import('...')`). Docs - Add a `## copy-package-json` subsection covering dual-declaration emission, the per-condition `exports` shape, and the `.mjs` rewrite rules with the rationale for `.mjs` over `.js`. - The original feature (PR #120) shipped without README coverage; this fills that gap alongside the specifier-extension fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- readme.md | 25 +++++++++++++++++++ .../copy-package-json-from-config.spec.ts | 17 +++++++++++++ 2 files changed, 42 insertions(+) diff --git a/readme.md b/readme.md index 0361c0a..bd4893b 100644 --- a/readme.md +++ b/readme.md @@ -83,6 +83,31 @@ export default config File paths used in this config file should point to the typescript file relative to the source directory. The tool will translate this to relevant js/mjs/d.ts paths in the out directory. +### Dual type declarations (`exportTypes: 'both'`) + +When `exportTypes` is `'both'`, the rewritten `package.json` declares per-condition `types` so each consumer resolves declarations in their own module system: + +```jsonc +"exports": { + ".": { + "import": { "types": "./index.d.mts", "default": "./index.mjs" }, + "require": { "types": "./index.d.cts", "default": "./index.js" } + } +} +``` + +For TypeScript to honour those conditions, the `.d.mts` and `.d.cts` files actually have to exist. `copy-package-json` produces them by duplicating each `.d.ts` emitted by the build: + +- `*.d.cts` — byte-for-byte copy of `*.d.ts`. CJS resolution accepts extensionless relative specifiers, so no rewriting is needed. +- `*.d.mts` — copy with relative specifiers rewritten so the resolver pairs each declaration with its `.d.mts` twin rather than the `.d.ts`. Concretely: + - `from './foo'` → `from './foo.mjs'` (when `./foo.d.ts` or `./foo.d.mts` exists) + - `from './foo'` → `from './foo/index.mjs'` (when `./foo/index.d.ts` exists) + - `from './foo.js'`, `from 'some-package'`, and unresolvable paths are left alone. + +The reason for `.mjs` (not `.js`): under `moduleResolution: "node16"`/`"nodenext"`, a `.js` specifier inside a `.d.mts` resolves against the adjacent `.d.ts`, which in a dual-published package whose root `package.json` has `"type": "commonjs"` is treated as CJS-flavoured. Strict-ESM consumers then surface type-resolution mismatches. Using `.mjs` keeps the resolution chain in ESM throughout. + +If your build already emits `.d.mts`/`.d.cts` directly, `copy-package-json` won't overwrite them — duplication only fills in missing siblings. + ## Sub-Packages ### @makerx/eslint-config diff --git a/src/util/copy-package-json-from-config.spec.ts b/src/util/copy-package-json-from-config.spec.ts index bcb1189..ecf9c6b 100644 --- a/src/util/copy-package-json-from-config.spec.ts +++ b/src/util/copy-package-json-from-config.spec.ts @@ -35,12 +35,29 @@ describe('rewriteEsmRelativeImports', () => { expect(rewriteEsmRelativeImports(input, dir)).toBe(input) }) + it('Resolves file specifiers to .mjs so the .d.mts twin is paired under node16+ resolution', () => { + fs.writeFileSync(path.join(dir, 'helper.d.ts'), '', 'utf-8') + expect(rewriteEsmRelativeImports(`from './helper'`, dir)).toBe(`from './helper.mjs'`) + }) + it('Resolves directory specifiers to /index.mjs', () => { fs.mkdirSync(path.join(dir, 'sub')) fs.writeFileSync(path.join(dir, 'sub', 'index.d.ts'), '', 'utf-8') expect(rewriteEsmRelativeImports(`from './sub'`, dir)).toBe(`from './sub/index.mjs'`) }) + it('Resolves when only a .d.mts twin exists alongside the source', () => { + fs.writeFileSync(path.join(dir, 'esm-only.d.mts'), '', 'utf-8') + expect(rewriteEsmRelativeImports(`from './esm-only'`, dir)).toBe(`from './esm-only.mjs'`) + }) + + it('Rewrites all three module-specifier shapes in one pass', () => { + fs.writeFileSync(path.join(dir, 'helper.d.ts'), '', 'utf-8') + const input = [`import { x } from './helper'`, `import './helper'`, `type T = typeof import('./helper').x`].join('\n') + const expected = [`import { x } from './helper.mjs'`, `import './helper.mjs'`, `type T = typeof import('./helper.mjs').x`].join('\n') + expect(rewriteEsmRelativeImports(input, dir)).toBe(expected) + }) + it('Leaves specifiers that cannot be resolved alone', () => { expect(rewriteEsmRelativeImports(`from './does-not-exist'`, dir)).toBe(`from './does-not-exist'`) }) From 52ce45b91f15148de735bc352b4bfde7faaf11f2 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Sat, 25 Apr 2026 10:50:54 +0800 Subject: [PATCH 3/7] chore(release): 5.0.0-beta.1 Publishes the .d.mts specifier-extension fix so downstream packages can drop their local workarounds (e.g. graphql-apollo-server's build:4a-fix-dmts post-step). Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2fe8539..74489ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@makerx/ts-toolkit", - "version": "5.0.0-beta.0", + "version": "5.0.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@makerx/ts-toolkit", - "version": "5.0.0-beta.0", + "version": "5.0.0-beta.1", "license": "MIT", "dependencies": { "chalk": "^4.1.2", diff --git a/package.json b/package.json index c184cda..00d04fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makerx/ts-toolkit", - "version": "5.0.0-beta.0", + "version": "5.0.0-beta.1", "description": "This cli facilitates the creation of boilerplate files in a new typescript repo", "repository": "https://github.com/MakerXStudio/ts-toolkit", "type": "module", From 3dfbc32560f778b1ffb8417b5070b7b4f1be230e Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Sat, 25 Apr 2026 10:59:01 +0800 Subject: [PATCH 4/7] chore(audit): bump postcss to 8.5.10 to fix GHSA-qx2v-qp2m-jg93 Resolves the moderate XSS advisory (PostCSS Stringify Output unescaped ``) reaching us via vitest > vite > postcss in dev. Within the existing semver range; no package.json change, lockfile-only. Surfaced by the PR check on #121. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 74489ac..20e5e1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5304,9 +5304,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -5324,7 +5324,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, From 3310ce0bac80e49a426c7aa97935efe2f7530102 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Sat, 25 Apr 2026 11:03:45 +0800 Subject: [PATCH 5/7] docs+test: cover the .d.mts adjacency case for directory specifiers Addresses Copilot review comments on #121. `rewriteSpecifier` already resolves both `.d.ts` and `.d.mts` adjacents in the directory branch, but the README bullet and the source-comment example only mentioned `.d.ts`. Updated both to spell out either form. Also adds a sibling unit test covering the case where only an `index.d.mts` is present in a directory (matching the existing "only a .d.mts twin alongside the source" test for the file case). Co-Authored-By: Claude Opus 4.7 (1M context) --- readme.md | 2 +- src/util/copy-package-json-from-config.spec.ts | 6 ++++++ src/util/copy-package-json-from-config.ts | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/readme.md b/readme.md index bd4893b..0cfb470 100644 --- a/readme.md +++ b/readme.md @@ -101,7 +101,7 @@ For TypeScript to honour those conditions, the `.d.mts` and `.d.cts` files actua - `*.d.cts` — byte-for-byte copy of `*.d.ts`. CJS resolution accepts extensionless relative specifiers, so no rewriting is needed. - `*.d.mts` — copy with relative specifiers rewritten so the resolver pairs each declaration with its `.d.mts` twin rather than the `.d.ts`. Concretely: - `from './foo'` → `from './foo.mjs'` (when `./foo.d.ts` or `./foo.d.mts` exists) - - `from './foo'` → `from './foo/index.mjs'` (when `./foo/index.d.ts` exists) + - `from './foo'` → `from './foo/index.mjs'` (when `./foo/index.d.ts` or `./foo/index.d.mts` exists) - `from './foo.js'`, `from 'some-package'`, and unresolvable paths are left alone. The reason for `.mjs` (not `.js`): under `moduleResolution: "node16"`/`"nodenext"`, a `.js` specifier inside a `.d.mts` resolves against the adjacent `.d.ts`, which in a dual-published package whose root `package.json` has `"type": "commonjs"` is treated as CJS-flavoured. Strict-ESM consumers then surface type-resolution mismatches. Using `.mjs` keeps the resolution chain in ESM throughout. diff --git a/src/util/copy-package-json-from-config.spec.ts b/src/util/copy-package-json-from-config.spec.ts index ecf9c6b..54e5838 100644 --- a/src/util/copy-package-json-from-config.spec.ts +++ b/src/util/copy-package-json-from-config.spec.ts @@ -51,6 +51,12 @@ describe('rewriteEsmRelativeImports', () => { expect(rewriteEsmRelativeImports(`from './esm-only'`, dir)).toBe(`from './esm-only.mjs'`) }) + it('Resolves directory specifiers when only an index.d.mts is present', () => { + fs.mkdirSync(path.join(dir, 'esm-sub')) + fs.writeFileSync(path.join(dir, 'esm-sub', 'index.d.mts'), '', 'utf-8') + expect(rewriteEsmRelativeImports(`from './esm-sub'`, dir)).toBe(`from './esm-sub/index.mjs'`) + }) + it('Rewrites all three module-specifier shapes in one pass', () => { fs.writeFileSync(path.join(dir, 'helper.d.ts'), '', 'utf-8') const input = [`import { x } from './helper'`, `import './helper'`, `type T = typeof import('./helper').x`].join('\n') diff --git a/src/util/copy-package-json-from-config.ts b/src/util/copy-package-json-from-config.ts index 4ffa04b..3c98d21 100644 --- a/src/util/copy-package-json-from-config.ts +++ b/src/util/copy-package-json-from-config.ts @@ -119,8 +119,8 @@ function emitIfMissing(destination: string, produceContent: () => string): numbe } // Rewrites relative specifiers in a declaration file for ESM resolution: -// from './x' → from './x.mjs' (when ./x.d.ts exists) -// from './x' → from './x/index.mjs' (when ./x/index.d.ts exists) +// from './x' → from './x.mjs' (when ./x.d.ts or ./x.d.mts exists) +// from './x' → from './x/index.mjs' (when ./x/index.d.ts or ./x/index.d.mts exists) // The .mjs extension pairs the specifier with the adjacent .d.mts declaration // under TS's node16+ resolver; using .js would resolve against the .d.ts // (CJS-flavoured in a dual-published package) and surface as type errors in From 6a79daf8152c3f842556d7a707b75c76e91abb12 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Tue, 28 Apr 2026 16:46:52 +0800 Subject: [PATCH 6/7] use interop: 'auto' as default for rollup CJS output --- src/templates/node/rollup.config.ts.sample | 1 + 1 file changed, 1 insertion(+) diff --git a/src/templates/node/rollup.config.ts.sample b/src/templates/node/rollup.config.ts.sample index 728e8bb..e6b7043 100644 --- a/src/templates/node/rollup.config.ts.sample +++ b/src/templates/node/rollup.config.ts.sample @@ -14,6 +14,7 @@ const config: RollupOptions = { exports: 'named', preserveModules: true, sourcemap: true, + interop: 'auto', }, { dir: 'dist', From 59dcefadbab74e06e334a4e814eb8be1e164cec8 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Tue, 28 Apr 2026 16:50:58 +0800 Subject: [PATCH 7/7] fix default rollup external function to correctly switch between local and imported module code --- src/templates/node/rollup.config.ts.sample | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/templates/node/rollup.config.ts.sample b/src/templates/node/rollup.config.ts.sample index e6b7043..5c62df1 100644 --- a/src/templates/node/rollup.config.ts.sample +++ b/src/templates/node/rollup.config.ts.sample @@ -4,6 +4,9 @@ import typescript from '@rollup/plugin-typescript' import json from '@rollup/plugin-json' import type { RollupOptions } from 'rollup' +const isBareModuleImport = (id: string, importer: string | undefined) => + importer !== undefined && !id.startsWith('.') && !isAbsolute(id) + const config: RollupOptions = { input: ['src/index.ts'], output: [ @@ -29,7 +32,7 @@ const config: RollupOptions = { moduleSideEffects: false, propertyReadSideEffects: false, }, - external: [/node_modules/], + external: isBareModuleImport, plugins: [ typescript({ tsconfig: 'tsconfig.build.json',