diff --git a/package-lock.json b/package-lock.json index 2fe8539..20e5e1e 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", @@ -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" }, 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", diff --git a/readme.md b/readme.md index 0361c0a..0cfb470 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` 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. + +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 6139bd3..54e5838 100644 --- a/src/util/copy-package-json-from-config.spec.ts +++ b/src/util/copy-package-json-from-config.spec.ts @@ -35,10 +35,33 @@ describe('rewriteEsmRelativeImports', () => { expect(rewriteEsmRelativeImports(input, dir)).toBe(input) }) - it('Resolves directory specifiers to /index.js', () => { + 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.js'`) + 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('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') + 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', () => { @@ -190,14 +213,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..3c98d21 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 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 +// 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 }