Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Isaacs/imports unification #15

Merged
merged 3 commits into from
Oct 19, 2023
Merged
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
157 changes: 80 additions & 77 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,58 +113,103 @@ just be passed through as-is.
}
```

### `tshy.imports`
### Package `#imports`

You can use Node `package.json` `imports` in the `tshy` config,
referencing input files in `./src`. These will be copied into the
`package.json` files built into the `dist/{esm,commonjs}`
folders, so that they work like they would in a normal Node
program.
You can use `"imports"` in your package.json, and it will be
handled in the following ways.

The `tshy.imports` entries:
### Built Imports

- Must have a string key starting with `#`.
- Must have a string value starting with `'./src'`.
Any `"imports"` that resolve to a file built as part of your
program must be a non-conditional string value pointing to the
file in `./src/`. For example:

For example, you can do this:
```json
{
"imports": {
"#name": "./src/path/to/name.ts",
"#utils/*": "./src/path/to/utils/*.ts"
}
}
```

In the ESM build, `import * from '#name'` will resolve to
`./dist/esm/path/to/name.js`, and will be built for ESM. In the
CommonJS build, `require('#name')` will resolve to
`./dist/commonjs/path/to/name.js` and will be built for CommonJS.

<details>
<summary>tl;dr how this works and why it can't be conditional</summary>

In the built `dist/{dialect}/package.json` files, the `./src`
will be stripped from the path and their file extension changed
from `ts` to `js` (`cts` to `cjs` and `mts` to `mjs`).

It shouldn't be conditional, because the condition is already
implicit in the build. In the CommonJS build, they should be
required, and in the ESM builds, they should be imported, and
there's only one thing that it can resolve to from any given
build.

</details>

Any `"imports"` that resolve to something _not_ built by tshy,
then tshy will set `scripts.preinstall` to set up symbolic links
to make it work at install time. This just means that you can't
use `scripts.preinstall` for anything else if you have
`"imports"` that aren't managed by tshy. For example:

```json
{
"tshy": {
"imports": {
"#foo": "./src/lib/foo.ts",
"#utils/*": "./src/app/shared-components/utils/*"
"imports": {
"#dep": "@scope/dep/submodule",
"#conditional": {
"types": "./vendor/blah.d.ts",
"require": "./vendor/blah.cjs",
"import": "./vendor/blah.mjs
}
}
}
```

Then in your program, you can do this:
<details>
<summary>tl;dr explanation</summary>

```ts
// src/index.ts
import { foo } from '#foo'
import { barUtil } from '#utils/bar.js'
```
The `"imports"` field in package.json allows you to set local
package imports, which have the same kind of conditional import
logic as `"exports"`. This is especially useful when you have a
vendored dependency with `require` and `import` variants, modules
that have to be bundled in different ways for different
environments, or different dependencies for different
environments.

These package imports are _always_ resolved against the nearest
`package.json` file, and tshy uses generated package.json files
to set the module dialect to `"type":"module"` in `dist/esm` and
`"type":"commonjs"` in `dist/commonjs`, and it swaps the
`src/package.json` file between this during the `tsc` builds.

Furthermore, local package imports may not be relative files
outside the package folder. They may only be local files within
the local package, or dependencies resolved in `node_modules`.

When this is compiled to `./dist/esm/index.js`, it will
automatically map `#foo` to `./dist/esm/lib/foo.js` and
`#utils/bar.js` to
`./dist/esm/app/shared-components/utils/bar.js`.
To support this, tshy copies the `imports` field from the
project's package.json into these dialect-setting generated
package.json files, and creates symlinks into the appropriate
places so that they resolve to the same files on disk.

When using this feature, `tshy` will automatically update your
`./tsconfig.json` file to set the
[`paths`](https://www.typescriptlang.org/tsconfig#paths)
appropriately so that it can find the types.
Because symlinks may not be included in npm packages (and even if
they are included, they won't be unpacked at install time), the
symlinks it places in `./dist` wouldn't do much good. In order to
work around _this_ restriction, tshy creates a node program at
`dist/.tshy-link-imports.mjs`, which generates the symlinks at
install time via the `preinstall` script.

Note that you can _not_ set conditional imports in this way, so
you can't use this to have `#import` style module identifiers
pointing to something outside of the built `dist` folder. (That
_is_ supported with `imports` in the top level `package.json`,
with some caveats. See below.)
</details>

None of the keys in `tshy.imports` are allowed to conflict with
the keys in the `package.json`'s top-level `imports`.
_If a `tshy.imports` is present (a previous iteration of this
behavior), it will be merged into the top-level `"imports"` and
deleted from the `tshy` section._

### Making Noise

Expand Down Expand Up @@ -457,48 +502,6 @@ for this purpose, and then delete it afterwards. If that file
exists and _wasn't_ put there by `tshy`, then it will be
destroyed.

## Package `#imports` (outside of `tshy` config)

If you use `"imports"` in your package.json, then tshy will set
`scripts.preinstall` to set up some symbolic links to make it
work. This just means you can't use `scripts.preinstall` for
anything else if you use `"imports"`.

<details>
<summary>tl;dr explanation</summary>

The `"imports"` field in package.json allows you to set local
package imports, which have the same kind of conditional import
logic as `"exports"`. This is especially useful when you have a
vendored dependency with `require` and `import` variants, modules
that have to be bundled in different ways for different
environments, or different dependencies for different
environments.

These package imports are _always_ resolved against the nearest
`package.json` file, and tshy uses generated package.json files
to set the module dialect to `"type":"module"` in `dist/esm` and
`"type":"commonjs"` in `dist/commonjs`, and it swaps the
`src/package.json` file between this during the `tsc` builds.

Furthermore, local package imports may not be relative files
outside the package folder. They may only be local files within
the local package, or dependencies resolved in `node_modules`.

To support this, tshy copies the `imports` field from the
project's package.json into these dialect-setting generated
package.json files, and creates symlinks into the appropriate
places so that they resolve to the same files on disk.

Because symlinks may not be included in npm packages (and even if
they are included, they won't be unpacked at install time), the
symlinks it places in `./dist` wouldn't do much good. In order to
work around _this_ restriction, tshy creates a node program at
`dist/.tshy-link-imports.mjs`, which generates the symlinks at
install time via the `preinstall` script.

</details>

## Local Package `exports`

In order to facilitate local package exports, tshy will create a
Expand Down
45 changes: 0 additions & 45 deletions src/add-paths-to-tsconfig.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/build-fail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as console from './console.js'
import fail from './fail.js'
import setFolderDialect from './set-folder-dialect.js'
import './tsconfig.js'
import { unlink as unlinkImports } from './imports.js'
import { unlink as unlinkImports } from './unbuilt-imports.js'
import { unlink as unlinkSelfDep } from './self-dep.js'
import pkg from './package.js'

Expand Down
2 changes: 1 addition & 1 deletion src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
link as linkImports,
save as saveImports,
unlink as unlinkImports,
} from './imports.js'
} from './unbuilt-imports.js'
import pkg from './package.js'
import {
link as linkSelfDep,
Expand Down
22 changes: 22 additions & 0 deletions src/built-imports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// merge tshy.imports with package.json imports

import { Package } from './types.js'

// strip the ./src/ and turn ts extension into js for built imports
// leave unbuilt imports alone, they'll be symlinked
export default (pkg: Package): Package['imports'] => {
const { imports } = pkg
if (!imports) return undefined
return Object.fromEntries(
Object.entries(imports).map(([k, v]) => [
k,
typeof v === 'string' && v.startsWith('./src/')
? './' +
v
.substring('./src/'.length)
.replace(/\.([cm]?)ts$/, '.$1js')
.replace(/\.tsx$/, '.js')
: v,
])
)
}
18 changes: 16 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// get the config and package and stuff

import chalk from 'chalk'
import * as console from './console.js'
import fail from './fail.js'
import pkg from './package.js'
import sources from './sources.js'
Expand All @@ -23,14 +25,26 @@ const validConfig = (e: any): e is TshyConfig =>
(e.dialects === undefined || validDialects(e.dialects)) &&
validExtraDialects(e) &&
validBoolean(e, 'selfLink') &&
validBoolean(e, 'main') &&
validImports(e, pkg)
validBoolean(e, 'main')

const getConfig = (
pkg: Package,
sources: Set<string>
): TshyConfig => {
const tshy: TshyConfig = validConfig(pkg.tshy) ? pkg.tshy : {}
const ti = tshy as TshyConfig & { imports?: any }
if (ti.imports) {
console.debug(
chalk.cyan.dim('imports') +
' moving from tshy config to top level'
)
pkg.imports = {
...pkg.imports,
...ti.imports,
}
delete ti.imports
}
validImports(pkg)
if (tshy.exports) return tshy
const e: Exclude<TshyConfig['exports'], undefined> = {
'./package.json': './package.json',
Expand Down
27 changes: 0 additions & 27 deletions src/get-imports.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/set-folder-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import chalk from 'chalk'
import { writeFileSync } from 'fs'
import { rimrafSync } from 'rimraf'
import * as console from './console.js'
import getImports from './get-imports.js'
import getImports from './built-imports.js'
import pkg from './package.js'
import { Dialect } from './types.js'

Expand Down
6 changes: 2 additions & 4 deletions src/tsconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { join } from 'node:path/posix'
import * as console from './console.js'

// the commonjs build needs to exclude anything that will be polyfilled
import { addToFile, addToObject } from './add-paths-to-tsconfig.js'
import config from './config.js'
import polyfills from './polyfills.js'

Expand All @@ -21,7 +20,7 @@ const {
commonjsDialects = [],
} = config

const recommended: Record<string, any> = addToObject({
const recommended: Record<string, any> = {
compilerOptions: {
declaration: true,
declarationMap: true,
Expand All @@ -38,7 +37,7 @@ const recommended: Record<string, any> = addToObject({
strict: true,
target: 'es2022',
},
})
}

const build: Record<string, any> = {
extends: '../tsconfig.json',
Expand Down Expand Up @@ -101,7 +100,6 @@ if (!existsSync('tsconfig.json')) {
writeConfig('../tsconfig', recommended)
} else {
console.debug('using existing tsconfig.json')
addToFile()
}
for (const f of readdirSync('.tshy')) {
unlinkSync(resolve('.tshy', f))
Expand Down
1 change: 0 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type {

export type TshyConfig = {
exports?: Record<string, TshyExport>
imports?: Record<string, string>
dialects?: Dialect[]
selfLink?: boolean
main?: boolean
Expand Down
Loading