Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
25 changes: 25 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 28 additions & 5 deletions src/util/copy-package-json-from-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
24 changes: 14 additions & 10 deletions src/util/copy-package-json-from-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Expand Down