├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .vscode └── settings.json ├── build └── index.js ├── config ├── index.d.ts └── index.js ├── docs ├── configuration.md └── usage.md ├── license ├── logo.png ├── package.json ├── readme.md ├── src ├── bin.ts ├── loader.ts ├── require.ts ├── utils.d.ts └── utils.ts ├── test ├── config │ ├── index.ts │ └── tsm.js ├── defines.ts ├── fixtures │ ├── App1.jsx │ ├── App2.tsx │ ├── data.json │ ├── math.ts │ ├── mock.ts │ ├── module │ │ ├── index.js │ │ ├── index.mjs │ │ └── package.json │ ├── utils.cts │ └── utils.mts ├── index.js └── index.mjs └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{json,yml,md}] 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: lukeed 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | pull_request: 8 | branches: 9 | - '**' 10 | 11 | jobs: 12 | test: 13 | name: Node.js v${{ matrix.nodejs }} (${{ matrix.os }}) 14 | runs-on: ${{ matrix.os }} 15 | timeout-minutes: 3 16 | strategy: 17 | matrix: 18 | nodejs: [12, 14, 16.11, 16, 18] 19 | os: [ubuntu-latest, windows-latest] 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: actions/setup-node@v2 23 | with: 24 | node-version: ${{ matrix.nodejs }} 25 | 26 | - name: Install 27 | run: npm install 28 | 29 | - name: Compiles 30 | run: npm run build 31 | 32 | - name: Type Checks 33 | run: npm run types 34 | 35 | - name: Tests <~ ESM 36 | run: node --loader ./loader.mjs test/index.mjs 37 | 38 | - name: Tests <~ ESM <~ TypeScript 39 | run: node --loader ./loader.mjs test/config/index.ts --tsmconfig test/config/tsm.js 40 | 41 | - name: Tests <~ CommonJS 42 | run: node -r ./require.js test/index.js 43 | 44 | - name: Tests <~ CommonJS <~ TypeScript 45 | run: node -r ./require.js test/config/index.ts --tsmconfig test/config/tsm.js 46 | 47 | - name: Tests <~ CLI 48 | run: node bin.js test/index.mjs 49 | 50 | - name: Tests <~ CLI <~ TypeScript 51 | run: node bin.js test/config/index.ts --tsmconfig test/config/tsm.js 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *-lock.* 4 | *.lock 5 | *.log 6 | 7 | /bin.js 8 | /utils.js 9 | /require.js 10 | /loader.mjs 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /build/index.js: -------------------------------------------------------------------------------- 1 | const { build } = require('esbuild'); 2 | const pkg = require('../package.json'); 3 | 4 | (async function () { 5 | /** 6 | * @type {import('esbuild').BuildOptions} 7 | */ 8 | let shared = { 9 | logLevel: 'info', 10 | charset: 'utf8', 11 | minify: true, 12 | define: { 13 | VERSION: JSON.stringify(pkg.version) 14 | } 15 | }; 16 | 17 | await build({ 18 | ...shared, 19 | entryPoints: ['src/bin.ts'], 20 | outfile: pkg.bin, 21 | }); 22 | 23 | await build({ 24 | ...shared, 25 | entryPoints: ['src/utils.ts'], 26 | outfile: './utils.js', 27 | }); 28 | 29 | await build({ 30 | ...shared, 31 | entryPoints: ['src/require.ts'], 32 | outfile: pkg.exports['.'].require, 33 | }); 34 | 35 | await build({ 36 | ...shared, 37 | entryPoints: ['src/loader.ts'], 38 | outfile: pkg.exports['.'].import, 39 | }); 40 | })().catch(err => { 41 | console.error(err.stack || err); 42 | process.exitCode = 1; 43 | }); 44 | -------------------------------------------------------------------------------- /config/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Loader, TransformOptions } from 'esbuild'; 2 | 3 | export type Extension = `.${string}`; 4 | export type Options = TransformOptions; 5 | 6 | export type Config = { 7 | [extn: Extension]: Options; 8 | } 9 | 10 | export type ConfigFile = 11 | | { common?: Options; config?: Config; loaders?: never; [extn: Extension]: never } 12 | | { common?: Options; loaders?: Loaders; config?: never; [extn: Extension]: never } 13 | | { common?: Options; config?: never; loaders?: never; [extn: Extension]: Options } 14 | 15 | export type Loaders = { 16 | [extn: Extension]: Loader; 17 | } 18 | 19 | /** 20 | * TypeScript helper for writing `tsm.js` contents. 21 | */ 22 | export function define(contents: ConfigFile): ConfigFile; 23 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | exports.define=c=>c; 2 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | You must define configuration on a _per-extension_ basis. Any unknown extensions are ignored by tsm, deferring to Node's native handling of that file type. By default, tsm will _always_ handle the `.jsx`, `.tsx`, and `.ts` extensions, but you may redefine these in your configuration file to override default options. 4 | 5 | ## Options 6 | 7 | Every extension is given its own set of [`esbuild.transform`](https://esbuild.github.io/api/#transform-api) options. tsm provides moderate defaults per extension, but _does not_ enforce any option values or offer any custom options of its own. 8 | 9 | ### Defaults 10 | 11 | All extensions are loaded with these default `esbuild` options: 12 | 13 | > **Important:** Any configuration that you define also inherits these defaults. Provide a new value to override the default setting. 14 | 15 | ```js 16 | let options = { 17 | format: format, // "esm" for CLI or --loader, else "cjs" 18 | charset: 'utf8', 19 | sourcemap: 'inline', 20 | target: 'node' + process.versions.node, 21 | logLevel: isQuiet ? 'silent' : 'warning', 22 | color: enabled, // determined via `process` analysis 23 | } 24 | ``` 25 | 26 | Additionally, tsm defines a few extensions by default, each of which is assigned an appropriate [esbuild loader](https://esbuild.github.io/content-types/). The _entire_ default tsm configuration is as follows: 27 | 28 | ```js 29 | let config = { 30 | '.jsx': { ...options, loader: 'jsx' }, 31 | '.tsx': { ...options, loader: 'tsx' }, 32 | '.mts': { ...options, loader: 'ts' }, 33 | '.cts': { ...options, loader: 'ts' }, 34 | '.ts': { ...options, loader: 'ts' }, 35 | } 36 | ``` 37 | 38 | When using tsm through a [`--require` hook](/docs/usage.md#require-hook), then tsm also intercepts all `.mjs` files loaded via the `require()` method. 39 | 40 | With CLI and/or `--loader` usage, tsm also handles `.json` files, which allows you to `import` or `import()` a JSON file. 41 | 42 | 43 | ## Config File 44 | 45 | When a `tsm.js` file exists in the current working directory ([`process.cwd()`](https://nodejs.org/api/process.html#process_process_cwd)), it's automatically loaded and merged with the [tsm default configuration](#defaults). 46 | 47 | The module format of the `tsm.js` file is controlled by the root [`package.json` file](https://nodejs.org/api/esm.html#esm_enabling), also located in the current working directory. For example, if it contains `"type": "module"` then the `tsm.js` file may be written in ESM syntax (`import`/`export`). Otherwise it must be in CommonJS format. (This is true for all `.js` files.) 48 | 49 | Additionally, when using tsm as a `node` replacement, you may provide a path to an alternate configuration file through the `--tsmconfig` argument. For example, to load a `tsm.config.mjs` file, you should run: 50 | 51 | ```sh 52 | $ tsm server.ts --tsmconfig tsm.config.mjs 53 | ``` 54 | 55 | > **Note:** Any `--tsmconfig` value is always resolved from the `process.cwd()` 56 | 57 | When using tsm through a `--require` or `--loader` hook, the `--tsmconfig` flag is respected and your custom configuration file will be autoloaded, if found. 58 | 59 | 60 | ### Contents 61 | 62 | There multiple ways to define your configuration. 63 | 64 | > **Note:** See [Examples](#examples) below for demonstrations. 65 | 66 | Conceptually, configuration is broken down by extension, allowing each each extension to take its own `esbuild.transform` options. The extensions themselves are used as keys within the configuration object; for example, `.ts` **not** `ts`. While verbose, this is the clearest way to visualize and understand what/how each extension is handled. 67 | 68 | The extensions' configuration can remain free-standing, but you may also wrap it in a `config` key for added clarity. This object may be exported from the file as a named `config` export or the default export. 69 | 70 | However, as you might imagine, this may become overwhelming and/or repetitive. To alleviate this, tsm allows a `common` object to extract and share common options across _all_ extensions. The `common` key may coexist with all other configuration formats (see below) and may be exported from the configuration file as a named `common` export or as a `common` key on the default exported object. 71 | 72 | After extracting shared options to a `common` key, you may find that all your `config` is doing is defining an esbuild `loader` option. If this is the case, you may replace the `config` object with a `loaders` object that maps an extension to its (string) loader name. For example: 73 | 74 | ```diff 75 | --let config = { 76 | -- '.ts': { 77 | -- loader: 'ts' 78 | -- }, 79 | -- '.html': { 80 | -- loader: 'text' 81 | -- } 82 | --}; 83 | 84 | ++let loaders = { 85 | ++ '.ts': 'ts', 86 | ++ '.html': 'text', 87 | ++}; 88 | ``` 89 | 90 | However, when using the `loaders` approach, you **cannot** continue to use a `config` object. The `loaders` key may only coexist with the `common` options object. Should you need to add additional, extension-specific configuration, then you cannot use `loaders` and must use the `config` approach instead. 91 | 92 | Finally, the `tsm/config` submodule offers a `define` method that can be used to typecheck/validate your configuration. All previous approaches and combinations still apply when using the `define` helper. For simplicity, you should use this helper as your default export; for example: 93 | 94 | ```js 95 | // ESM syntax 96 | import { define } from 'tsm/config'; 97 | 98 | export default define({ 99 | common: { 100 | target: 'es2021', 101 | minify: true, 102 | }, 103 | 104 | '.ts': { 105 | minify: false, 106 | }, 107 | 108 | '.html': { 109 | loader: 'text', 110 | }, 111 | 112 | // ... 113 | }); 114 | ``` 115 | 116 | 117 | ### Examples 118 | 119 | > **Important:** Ignoring the authoring format (CommonJS vs ESM), all snippets produce the identical final configuration. 120 | 121 | ***Define each extension*** 122 | 123 | ```js 124 | let config = { 125 | '.ts': { 126 | minifyWhitespace: true, 127 | target: 'es2020', 128 | loader: 'ts', 129 | }, 130 | '.tsx': { 131 | jsxFactory: 'preact.h', 132 | jsxFragment: 'preact.Fragment', 133 | banner: 'import * as preact from "preact";', 134 | minifyWhitespace: true, 135 | target: 'es2020', 136 | loader: 'tsx', 137 | }, 138 | '.jsx': { 139 | jsxFactory: 'preact.h', 140 | jsxFragment: 'preact.Fragment', 141 | banner: 'import * as preact from "preact";', 142 | minifyWhitespace: true, 143 | target: 'es2020', 144 | loader: 'jsx', 145 | } 146 | }; 147 | 148 | /** 149 | * PICK ONE 150 | */ 151 | 152 | // ESM - default 153 | export default config; 154 | 155 | // ESM - named 156 | export { config }; 157 | 158 | // CommonJS - default 159 | module.exports = config; 160 | 161 | // CommonJS - named 162 | exports.config = config; 163 | ``` 164 | 165 | ***Hoist `common` options*** 166 | 167 | ```js 168 | // Merged with default options 169 | // Shared with *all* extensions 170 | let common = { 171 | minifyWhitespace: true, 172 | target: 'es2020', 173 | jsxFactory: 'preact.h', 174 | jsxFragment: 'preact.Fragment', 175 | banner: 'import * as preact from "preact";', 176 | } 177 | 178 | // Retain unique per-extension config 179 | let config = { 180 | '.ts': { 181 | loader: 'ts', 182 | }, 183 | '.tsx': { 184 | loader: 'tsx', 185 | }, 186 | '.jsx': { 187 | loader: 'jsx', 188 | } 189 | }; 190 | 191 | /** 192 | * PICK ONE 193 | */ 194 | 195 | // ESM - named 196 | export { config, common }; 197 | 198 | // CommonJS - named 199 | exports.config = config; 200 | exports.common = common; 201 | ``` 202 | 203 | ***Invoke `loaders` shortcut*** 204 | 205 | ```js 206 | // Merged with default options 207 | // Shared with *all* extensions 208 | let common = { 209 | minifyWhitespace: true, 210 | target: 'es2020', 211 | jsxFactory: 'preact.h', 212 | jsxFragment: 'preact.Fragment', 213 | banner: 'import * as preact from "preact";', 214 | } 215 | 216 | // When *only* need to define a loader 217 | // use `loaders` object as a shortcut 218 | // NOTE: 219 | // You CANNOT define `config` *and* `loaders`. 220 | // If both are present, only `loaders` will apply. 221 | let loaders = { 222 | '.ts': 'ts', 223 | '.tsx': 'tsx', 224 | '.jsx': 'jsx', 225 | }; 226 | 227 | /** 228 | * PICK ONE 229 | */ 230 | 231 | // ESM - named 232 | export { loaders, common }; 233 | 234 | // CommonJS - named 235 | exports.loaders = loaders; 236 | exports.common = common; 237 | ``` 238 | 239 | ***Import `define` helper*** 240 | 241 | ```js 242 | // Includes TypeScript checks 243 | // NOTE: use `require` for CommonJS 244 | import { define } from 'tsm/config'; 245 | 246 | export default define({ 247 | common: { 248 | minifyWhitespace: true, 249 | target: 'es2020', 250 | jsxFactory: 'preact.h', 251 | jsxFragment: 'preact.Fragment', 252 | banner: 'import * as preact from "preact";', 253 | }, 254 | 255 | // Here you can define (exclusive): 256 | // - a `config` object; 257 | // - a `loaders` object; 258 | // - inline extension configs; OR 259 | // - nothing else 260 | 261 | loaders: { 262 | '.ts': 'ts', 263 | '.tsx': 'tsx', 264 | '.jsx': 'jsx', 265 | } 266 | }); 267 | ``` 268 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | There are a number of ways you can use tsm in your project: 4 | 5 | 1. As a `node` CLI replacement 6 | 2. As a CommonJS [`--require`](https://nodejs.org/api/cli.html#cli_r_require_module) hook 7 | 3. As an ESM [`--loader`](https://nodejs.org/api/esm.html#esm_loaders) 8 | 4. As a [shell shebang](https://linuxize.com/post/bash-shebang/) interpreter 9 | 10 | ## CLI 11 | 12 | ***Examples*** 13 | 14 | ```sh 15 | # run a file 16 | $ tsm server.tsx 17 | 18 | # run a file w/ Node flags 19 | # NOTE: flags are forwarded to `node` directly 20 | $ tsm main.ts --trace-warnings 21 | 22 | # run a file w/ ENV variables 23 | # NOTE: any ENV is forwarded to `node` directly 24 | $ NO_COLOR=1 PORT=8080 tsm main.ts 25 | 26 | # use npx/pnpx with tsm 27 | $ pnpx tsm server.tsx 28 | $ npx tsm server.tsx 29 | ``` 30 | 31 | ## Require Hook 32 | 33 | The `--require` hook has existed for a _very_ long time and many tools throughout the ecosystem support/integrate with this feature. However, not _every_ tool – so please consult with your tools' documentation to see if they support a `-r/--require` flag. 34 | 35 | > **Background:** Essentially, the `--require/-r` hook subjects any `require()`d file to additional and/or custom transformation(s). Even though this works on a per-extension basis, it can be quite costly (performance-wise) and there is discouraged; however, for tools like `tsm`, it's still valuable. 36 | 37 | A [configuration file](/docs/configuration.md#config-file) is still auto-loaded (if exists) when using `--require tsm` or `-r tsm`. 38 | 39 | ***Examples*** 40 | 41 | ```sh 42 | # node with require hook(s) 43 | $ node --require tsm server.tsx 44 | $ node -r dotenv/register -r tsm server.tsx 45 | 46 | # external tool with require hook support 47 | $ uvu -r tsm packages tests 48 | $ uvu --require tsm 49 | ``` 50 | 51 | ## Loader Hook 52 | 53 | The `--loader` hook is ESM's version of the `--require` hook. A loader is **only** applied to file(s) loaded through `import` or `import()` – anything loaded through `require` is ignored by the loader. 54 | 55 | > **Important:** ESM loaders are **experimental** and _will be_ redesigned. tsm will conform to new design(s) as the feature stabilizes. 56 | 57 | You may use `--loader tsm` or `--experimental-loader tsm` anywhere that supports ESM loaders. At time of writing, this seems to be limited to `node` itself. 58 | 59 | A [configuration file](/docs/configuration.md#config-file) is still auto-loaded (if exists) when using `--loader tsm`. 60 | 61 | ***Examples*** 62 | 63 | ```sh 64 | # run node with tsm loader 65 | $ node --loader tsm server.tsx 66 | $ node --experimental-loader tsm main.ts 67 | ``` 68 | 69 | ## Shell / Shebang 70 | 71 | If you have `tsm` installed globally on your system, you may write shell scripts with tsm as the interpreter. Here's an example: 72 | 73 | ```ts 74 | // file: example.ts 75 | #!/usr/bin/env tsm 76 | import { sum } from './math'; 77 | import { greet } from './hello'; 78 | 79 | let [who] = process.argv.slice(2); 80 | 81 | greet(who || 'myself'); 82 | 83 | let answer = sum(11, 31); 84 | console.log('the answer is:', answer); 85 | 86 | // file: math.ts 87 | export const sum = (a: number, b: number) => a + b; 88 | 89 | // file: hello.ts 90 | export function greet(name: string) { 91 | console.log(`Hello, ${name}~!`); 92 | } 93 | ``` 94 | 95 | > **Important:** These are three separate TypeScript files. 96 | 97 | Here, the main `example.ts` file imports/references functionality defined in the two other files. Additionally, the first line within `example.ts` contains a shebang (`#!`) followed by `/usr/bin/env tsm`, which tells the shell to use the `tsm` binary within the user's environment to process this file. Effectively, this means that the shebang is a shortcut for running this in your terminal: 98 | 99 | ```sh 100 | $ tsm example.ts 101 | ``` 102 | 103 | However, by including the shebang, you are embedding the instructions for _how_ this file should be executed. This also allows you to include additional CLI flags within the shebang, meaning you don't have to redefine or remember them later on. For example, you can forward the `--trace-warnings` argument through tsm, which will always be there whenever the `example.ts` script executes. 104 | 105 | ```diff 106 | --#!/usr/bin/env tsm 107 | ++#!/usr/bin/env tsm --trace-warnings 108 | ``` 109 | 110 | Now, in order to actually execute the `example.ts` script directly, you have to modify its permissions and mark it as executable: 111 | 112 | ```sh 113 | $ chmod +x example.ts 114 | ``` 115 | 116 | At this point, you can run the file directly in your terminal: 117 | 118 | ```sh 119 | $ ./example.ts 120 | # ExperimentalWarning: --experimental-loader is an experimental feature. This feature could change at any time 121 | # at emitExperimentalWarning (node:internal/util:227:11) 122 | # at initializeLoader (node:internal/process/esm_loader:54:3) 123 | # at Object.loadESM (node:internal/process/esm_loader:67:11) 124 | # at runMainESM (node:internal/modules/run_main:46:31) 125 | # at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:76:5) 126 | # at node:internal/main/run_main_module:17:47 127 | # Hello, myself~! 128 | # the answer is: 42 129 | ``` 130 | 131 | > **Note:** The large block of `ExperimentalWarning` text is from the `--trace-warnings` argument. This flag is forwarded to `node`, which prints this output natively. 132 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Luke Edwards (lukeed.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukeed/tsm/37e122be347baba7d6dd03356903d5a9f98f1f6f/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsm", 3 | "version": "2.3.0", 4 | "repository": "lukeed/tsm", 5 | "description": "TypeScript Module Loader", 6 | "license": "MIT", 7 | "bin": "bin.js", 8 | "author": { 9 | "name": "Luke Edwards", 10 | "email": "luke.edwards05@gmail.com", 11 | "url": "https://lukeed.com" 12 | }, 13 | "exports": { 14 | ".": { 15 | "import": "./loader.mjs", 16 | "require": "./require.js" 17 | }, 18 | "./config": "./config/index.js", 19 | "./package.json": "./package.json" 20 | }, 21 | "files": [ 22 | "bin.js", 23 | "utils.js", 24 | "require.js", 25 | "loader.mjs", 26 | "config" 27 | ], 28 | "engines": { 29 | "node": ">=12" 30 | }, 31 | "scripts": { 32 | "build": "node build", 33 | "types": "tsc --skipLibCheck" 34 | }, 35 | "dependencies": { 36 | "esbuild": "^0.15.16" 37 | }, 38 | "devDependencies": { 39 | "@types/node": "16.11.6", 40 | "@types/react": "17.0.33", 41 | "typescript": "4.9.3" 42 | }, 43 | "keywords": [ 44 | "esm", 45 | "loader", 46 | "typescript", 47 | "loader hook", 48 | "require hook", 49 | "experimental-loader" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 | tsm 3 |
4 | 5 |
6 | 7 | version 8 | 9 | 10 | CI 11 | 12 | 13 | licenses 14 | 15 | 16 | downloads 17 | 18 | 19 | publish size 20 | 21 |
22 | 23 |
TypeScript Module Loader
24 | 25 | ## Features 26 | 27 | * Supports `node ` usage 28 | * Supports [ESM `--loader`](https://nodejs.org/api/esm.html#esm_loaders) usage 29 | * Supports [`--require` hook](https://nodejs.org/api/cli.html#cli_r_require_module) usage 30 | * Optional [configuration](/docs/configuration.md) file for per-extension customization 31 | 32 | > The ESM Loader API is still **experimental** and will change in the future. 33 | 34 | ## Install 35 | 36 | ```sh 37 | # install as project dependency 38 | $ npm install --save-dev tsm 39 | 40 | # or install globally 41 | $ npm install --global tsm 42 | ``` 43 | 44 | ## Usage 45 | 46 | > **Note:** Refer to [`/docs/usage.md`](/docs/usage.md) for more information. 47 | 48 | ```sh 49 | # use as `node` replacement 50 | $ tsm server.ts 51 | 52 | # forwards any `node` ENV or flags 53 | $ NO_COLOR=1 tsm server.ts --trace-warnings 54 | 55 | # use as `--require` hook 56 | $ node --require tsm server.tsx 57 | $ node -r tsm server.tsx 58 | 59 | # use as `--loader` hook 60 | $ node --loader tsm main.jsx 61 | ``` 62 | 63 | ## License 64 | 65 | MIT © [Luke Edwards](https://lukeed.com) 66 | -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | let argv = process.argv.slice(2); 3 | 4 | // note: injected @ build 5 | declare const VERSION: string; 6 | 7 | if (argv.includes('-h') || argv.includes('--help')) { 8 | let msg = ''; 9 | msg += '\n Usage\n $ tsm [options] -- \n'; 10 | msg += '\n Options'; 11 | msg += `\n --tsmconfig Configuration file path (default: tsm.js)`; 12 | msg += `\n --quiet Silence all terminal messages`; 13 | msg += `\n --version Displays current version`; 14 | msg += '\n --help Displays this message\n'; 15 | msg += '\n Examples'; 16 | msg += '\n $ tsm server.ts'; 17 | msg += '\n $ node -r tsm input.jsx'; 18 | msg += '\n $ node --loader tsm input.jsx'; 19 | msg += '\n $ NO_COLOR=1 tsm input.jsx --trace-warnings'; 20 | msg += '\n $ tsm server.tsx --tsmconfig tsm.mjs\n'; 21 | console.log(msg); 22 | process.exit(0); 23 | } 24 | 25 | if (argv.includes('-v') || argv.includes('--version')) { 26 | console.log(`tsm, v${VERSION}`); 27 | process.exit(0); 28 | } 29 | 30 | let { URL, pathToFileURL } = require('url') as typeof import('url'); 31 | argv = ['--enable-source-maps', '--loader', new URL('loader.mjs', pathToFileURL(__filename)).href, ...argv]; 32 | require('child_process').spawn('node', argv, { stdio: 'inherit' }).on('exit', process.exit); 33 | -------------------------------------------------------------------------------- /src/loader.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, promises as fs } from 'fs'; 2 | import { fileURLToPath, URL } from 'url'; 3 | import * as tsm from './utils.js'; 4 | 5 | import type { Config, Extension, Options } from 'tsm/config'; 6 | type TSM = typeof import('./utils.d'); 7 | 8 | let config: Config; 9 | let esbuild: typeof import('esbuild'); 10 | 11 | let env = (tsm as TSM).$defaults('esm'); 12 | let setup = env.file && import('file:///' + env.file); 13 | 14 | type Promisable = Promise | T; 15 | type Source = string | SharedArrayBuffer | Uint8Array; 16 | type Format = 'builtin' | 'commonjs' | 'json' | 'module' | 'wasm'; 17 | 18 | type Resolve = ( 19 | ident: string, 20 | context: { 21 | conditions: string[]; 22 | parentURL?: string; 23 | }, 24 | fallback: Resolve 25 | ) => Promisable<{ 26 | url: string; 27 | shortCircuit: boolean; 28 | format?: Format; 29 | }>; 30 | 31 | type Inspect = ( 32 | url: string, 33 | context: object, 34 | fallback: Inspect 35 | ) => Promisable<{ format: Format }>; 36 | 37 | type Transform = ( 38 | source: Source, 39 | context: Record<'url' | 'format', string>, 40 | fallback: Transform 41 | ) => Promisable<{ source: Source }>; 42 | 43 | type LoadResult = Promisable<{ 44 | format: Format; 45 | shortCircuit: boolean; 46 | source: Source; 47 | }> 48 | 49 | type Load = ( 50 | url: string, 51 | context: { format?: Format }, 52 | fallback: (url: string, context: { format?: Format }) => LoadResult 53 | ) => LoadResult; 54 | 55 | async function toConfig(): Promise { 56 | let mod = await setup; 57 | mod = mod && mod.default || mod; 58 | return (tsm as TSM).$finalize(env, mod); 59 | } 60 | 61 | const EXTN = /\.\w+(?=\?|$)/; 62 | const isTS = /\.[mc]?tsx?(?=\?|$)/; 63 | 64 | async function toOptions(uri: string): Promise { 65 | config = config || await toConfig(); 66 | let [extn] = EXTN.exec(uri) || []; 67 | return config[extn as `.${string}`]; 68 | } 69 | 70 | function check(fileurl: string): string | void { 71 | let tmp = fileURLToPath(fileurl); 72 | if (existsSync(tmp)) return fileurl; 73 | } 74 | 75 | /** 76 | * extension aliases; runs after checking for extn on disk 77 | * @example `import('./foo.mjs')` but only `foo.mts` exists 78 | */ 79 | const MAPs: Record = { 80 | '.js': ['.ts', '.tsx', '.jsx'], 81 | '.jsx': ['.tsx'], 82 | '.mjs': ['.mts'], 83 | '.cjs': ['.cts'], 84 | }; 85 | 86 | const root = new URL('file:///' + process.cwd() + '/'); 87 | export const resolve: Resolve = async function (ident, context, fallback) { 88 | // ignore "prefix:" and non-relative identifiers 89 | if (/^\w+\:?/.test(ident)) return fallback(ident, context, fallback); 90 | 91 | let target = new URL(ident, context.parentURL || root); 92 | let ext: Extension, path: string | void, arr: Extension[]; 93 | let match: RegExpExecArray | null, i=0, base: string; 94 | 95 | // source ident includes extension 96 | if (match = EXTN.exec(target.href)) { 97 | ext = match[0] as Extension; 98 | if (!context.parentURL || isTS.test(ext)) { 99 | return { url: target.href, shortCircuit: true }; 100 | } 101 | 102 | // target ident exists 103 | if (path = check(target.href)) { 104 | return { url: path, shortCircuit: true }; 105 | } 106 | 107 | // target is virtual alias 108 | if (arr = MAPs[ext]) { 109 | base = target.href.substring(0, match.index); 110 | for (; i < arr.length; i++) { 111 | if (path = check(base + arr[i])) { 112 | i = match.index + ext.length; 113 | return { 114 | shortCircuit: true, 115 | url: i > target.href.length 116 | // handle target `?args` trailer 117 | ? base + target.href.substring(i) 118 | : path 119 | }; 120 | } 121 | } 122 | } 123 | 124 | // return original behavior, let it error 125 | return fallback(ident, context, fallback); 126 | } 127 | 128 | config = config || await toConfig(); 129 | 130 | for (ext in config) { 131 | path = check(target.href + ext); 132 | if (path) return { url: path, shortCircuit: true }; 133 | } 134 | 135 | return fallback(ident, context, fallback); 136 | } 137 | 138 | export const load: Load = async function (uri, context, fallback) { 139 | // note: inline `getFormat` 140 | let options = await toOptions(uri); 141 | if (options == null) return fallback(uri, context); 142 | let format: Format = options.format === 'cjs' ? 'commonjs' : 'module'; 143 | 144 | // TODO: decode SAB/U8 correctly 145 | let path = fileURLToPath(uri); 146 | let source = await fs.readFile(path); 147 | 148 | // note: inline `transformSource` 149 | esbuild = esbuild || await import('esbuild'); 150 | let result = await esbuild.transform(source.toString(), { 151 | ...options, 152 | sourcefile: path, 153 | format: format === 'module' ? 'esm' : 'cjs', 154 | }); 155 | 156 | return { format, source: result.code, shortCircuit: true }; 157 | } 158 | 159 | /** @deprecated */ 160 | export const getFormat: Inspect = async function (uri, context, fallback) { 161 | let options = await toOptions(uri); 162 | if (options == null) return fallback(uri, context, fallback); 163 | return { format: options.format === 'cjs' ? 'commonjs' : 'module' }; 164 | } 165 | 166 | /** @deprecated */ 167 | export const transformSource: Transform = async function (source, context, xform) { 168 | let options = await toOptions(context.url); 169 | if (options == null) return xform(source, context, xform); 170 | 171 | // TODO: decode SAB/U8 correctly 172 | esbuild = esbuild || await import('esbuild'); 173 | let result = await esbuild.transform(source.toString(), { 174 | ...options, 175 | sourcefile: context.url, 176 | format: context.format === 'module' ? 'esm' : 'cjs', 177 | }); 178 | 179 | return { source: result.code }; 180 | } 181 | -------------------------------------------------------------------------------- /src/require.ts: -------------------------------------------------------------------------------- 1 | const { readFileSync } = require('fs'); 2 | const { extname } = require('path'); 3 | const tsm = require('./utils'); 4 | 5 | import type { Config, Extension, Options } from 'tsm/config'; 6 | type TSM = typeof import('./utils.d'); 7 | 8 | type Module = NodeJS.Module & { 9 | _compile?(source: string, filename: string): typeof loader; 10 | }; 11 | 12 | const loadJS = require.extensions['.js']; 13 | 14 | let esbuild: typeof import('esbuild'); 15 | let env = (tsm as TSM).$defaults('cjs'); 16 | let uconf = env.file && require(env.file); 17 | let config: Config = (tsm as TSM).$finalize(env, uconf); 18 | 19 | declare const $$req: NodeJS.Require; 20 | const tsrequire = 'var $$req=require("module").createRequire(__filename);require=(' + function () { 21 | let { existsSync } = $$req('fs') as typeof import('fs'); 22 | let $url = $$req('url') as typeof import('url'); 23 | 24 | return new Proxy(require, { 25 | // NOTE: only here if source is TS 26 | apply(req, ctx, args: [id: string]) { 27 | let [ident] = args; 28 | if (!ident) return req.apply(ctx || $$req, args); 29 | 30 | // ignore "prefix:" and non-relative identifiers 31 | if (/^\w+\:?/.test(ident)) return $$req(ident); 32 | 33 | // exit early if no extension provided 34 | let match = /\.([mc])?[tj]sx?(?=\?|$)/.exec(ident); 35 | if (match == null) return $$req(ident); 36 | 37 | let base = $url.pathToFileURL(__filename); 38 | let file = $url.fileURLToPath(new $url.URL(ident, base)); 39 | if (existsSync(file)) return $$req(ident); 40 | 41 | let extn = match[0] as Extension; 42 | let rgx = new RegExp(extn + '$'); 43 | 44 | // [cm]?jsx? -> [cm]?tsx? 45 | let tmp = file.replace(rgx, extn.replace('js', 'ts')); 46 | if (existsSync(tmp)) return $$req(tmp); 47 | 48 | // look for ".[tj]sx" if ".js" given & still here 49 | if (extn === '.js') { 50 | tmp = file.replace(rgx, '.tsx'); 51 | if (existsSync(tmp)) return $$req(tmp); 52 | 53 | tmp = file.replace(rgx, '.jsx'); 54 | if (existsSync(tmp)) return $$req(tmp); 55 | } 56 | 57 | // let it error 58 | return $$req(ident); 59 | } 60 | }) 61 | } + ')();'; 62 | 63 | function transform(source: string, options: Options): string { 64 | esbuild = esbuild || require('esbuild'); 65 | return esbuild.transformSync(source, options).code; 66 | } 67 | 68 | function loader(Module: Module, sourcefile: string) { 69 | let extn = extname(sourcefile) as Extension; 70 | 71 | let options = config[extn] || {}; 72 | let pitch = Module._compile!.bind(Module); 73 | options.sourcefile = sourcefile; 74 | 75 | if (/\.[mc]?[tj]sx?$/.test(extn)) { 76 | options.banner = tsrequire + (options.banner || ''); 77 | // https://github.com/lukeed/tsm/issues/27 78 | options.supported = options.supported || {}; 79 | options.supported['dynamic-import'] = false; 80 | } 81 | 82 | if (config[extn] != null) { 83 | Module._compile = source => { 84 | let result = transform(source, options); 85 | return pitch(result, sourcefile); 86 | }; 87 | } 88 | 89 | try { 90 | return loadJS(Module, sourcefile); 91 | } catch (err) { 92 | let ec = err && (err as any).code; 93 | if (ec !== 'ERR_REQUIRE_ESM') throw err; 94 | 95 | let input = readFileSync(sourcefile, 'utf8'); 96 | let result = transform(input, { ...options, format: 'cjs' }); 97 | return pitch(result, sourcefile); 98 | } 99 | } 100 | 101 | for (let extn in config) { 102 | require.extensions[extn] = loader; 103 | } 104 | 105 | if (config['.js'] == null) { 106 | require.extensions['.js'] = loader; 107 | } 108 | -------------------------------------------------------------------------------- /src/utils.d.ts: -------------------------------------------------------------------------------- 1 | import type { Format } from 'esbuild'; 2 | import type { Config, Options } from '../config'; 3 | 4 | export interface Defaults { 5 | file: string | false; 6 | isESM: boolean; 7 | options: Options; 8 | } 9 | 10 | export function $defaults(format: Format): Defaults; 11 | export function $finalize(env: Defaults, custom?: Config): Config; 12 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | const { existsSync } = require('fs'); 3 | 4 | import type { Format } from 'esbuild'; 5 | import type * as tsm from 'tsm/config'; 6 | import type { Defaults } from './utils.d'; 7 | 8 | exports.$defaults = function (format: Format): Defaults { 9 | let { FORCE_COLOR, NO_COLOR, NODE_DISABLE_COLORS, TERM } = process.env; 10 | 11 | let argv = process.argv.slice(2); 12 | 13 | let flags = new Set(argv); 14 | let isQuiet = flags.has('-q') || flags.has('--quiet'); 15 | 16 | // @see lukeed/kleur 17 | let enabled = !NODE_DISABLE_COLORS && NO_COLOR == null && TERM !== 'dumb' && ( 18 | FORCE_COLOR != null && FORCE_COLOR !== '0' || process.stdout.isTTY 19 | ); 20 | 21 | let idx = flags.has('--tsmconfig') ? argv.indexOf('--tsmconfig') : -1; 22 | let file = resolve('.', !!~idx && argv[++idx] || 'tsm.js'); 23 | 24 | return { 25 | file: existsSync(file) && file, 26 | isESM: format === 'esm', 27 | options: { 28 | format: format, 29 | charset: 'utf8', 30 | sourcemap: 'inline', 31 | target: 'node' + process.versions.node, 32 | logLevel: isQuiet ? 'silent' : 'warning', 33 | color: enabled, 34 | } 35 | }; 36 | }; 37 | 38 | exports.$finalize = function (env: Defaults, custom?: tsm.ConfigFile): tsm.Config { 39 | let base = env.options; 40 | if (custom && custom.common) { 41 | Object.assign(base, custom.common!); 42 | delete custom.common; // loop below 43 | } 44 | 45 | let config: tsm.Config = { 46 | '.mts': { ...base, loader: 'ts' }, 47 | '.jsx': { ...base, loader: 'jsx' }, 48 | '.tsx': { ...base, loader: 'tsx' }, 49 | '.cts': { ...base, loader: 'ts' }, 50 | '.ts': { ...base, loader: 'ts' }, 51 | }; 52 | 53 | if (env.isESM) { 54 | config['.json'] = { ...base, loader: 'json' }; 55 | } else { 56 | config['.mjs'] = { ...base, loader: 'js' }; 57 | } 58 | 59 | let extn: tsm.Extension; 60 | if (custom && custom.loaders) { 61 | for (extn in custom.loaders) config[extn] = { 62 | ...base, loader: custom.loaders[extn] 63 | }; 64 | } else if (custom) { 65 | let conf = (custom.config || custom) as tsm.Config; 66 | for (extn in conf) config[extn] = { ...base, ...conf[extn] }; 67 | } 68 | 69 | return config; 70 | } 71 | -------------------------------------------------------------------------------- /test/config/index.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | // NOTE: doesn't actually exist yet 4 | import * as js from '../fixtures/math.js'; 5 | import * as mjs from '../fixtures/utils.mjs'; 6 | import * as cjs from '../fixtures/utils.cjs'; 7 | 8 | import * as esm1 from '../fixtures/module/index.js'; 9 | import * as esm2 from '../fixtures/module/index.mjs'; 10 | 11 | // NOTE: avoid need for syntheticDefault + analysis 12 | import * as data from '../fixtures/data.json'; 13 | 14 | // NOTE: for CJS test runner 15 | (async function () { 16 | assert.equal(typeof data, 'object'); 17 | 18 | // @ts-ignore - generally doesn't exist 19 | assert.equal(typeof data.default, 'string'); 20 | 21 | // NOTE: raw JS missing 22 | assert.equal(typeof js, 'object', 'JS :: typeof'); 23 | assert.equal(typeof js.sum, 'function', 'JS :: typeof :: sum'); 24 | assert.equal(typeof js.div, 'function', 'JS :: typeof :: div'); 25 | assert.equal(typeof js.mul, 'function', 'JS :: typeof :: mul'); 26 | assert.equal(js.foobar, 3, 'JS :: value :: foobar'); 27 | 28 | // DYANMIC IMPORTS via TS file 29 | assert.equal(typeof js.dynamic, 'object', 'JS :: typeof :: dynamic'); 30 | assert.equal(await js.dynamic.cjs(), 'foo-bar', 'JS :: dynamic :: import(cjs)'); 31 | assert.equal(await js.dynamic.cts(), 'foo-bar', 'JS :: dynamic :: import(cts)'); 32 | assert.equal(await js.dynamic.mjs(), 'Hello', 'JS :: dynamic :: import(mjs)'); 33 | assert.equal(await js.dynamic.mts(), 'Hello', 'JS :: dynamic :: import(mts)'); 34 | 35 | // NOTE: raw MJS missing 36 | assert.equal(typeof mjs, 'object', 'MJS :: typeof'); 37 | assert.equal(typeof mjs.capitalize, 'function', 'MJS :: typeof :: capitalize'); 38 | assert.equal(mjs.capitalize('hello'), 'Hello', 'MJS :: value :: capitalize'); 39 | 40 | // NOTE: raw CJS missing 41 | assert.equal(typeof cjs, 'object', 'CJS :: typeof'); 42 | assert.equal(typeof cjs.dashify, 'function', 'CJS :: typeof :: dashify'); 43 | assert.equal(cjs.dashify('FooBar'), 'foo-bar', 'CJS :: value :: dashify'); 44 | 45 | // Checking ".js" with ESM content (type: module) 46 | assert.equal(typeof esm1, 'object', 'ESM.js :: typeof'); 47 | assert.equal(typeof esm1.hello, 'function', 'ESM.js :: typeof :: hello'); 48 | assert.equal(esm1.hello('you'), 'hello, you', 'ESM.js :: value :: hello'); 49 | 50 | // DYANMIC IMPORTS via JS file 51 | assert.equal(typeof esm1.dynamic, 'object', 'ESM.js :: typeof :: dynamic'); 52 | assert.equal(await esm1.dynamic.cjs(), 'foo-bar', 'ESM.js :: dynamic :: import(cjs)'); 53 | assert.equal(await esm1.dynamic.cts(), 'foo-bar', 'ESM.js :: dynamic :: import(cts)'); 54 | assert.equal(await esm1.dynamic.mjs(), 'Hello', 'ESM.js :: dynamic :: import(mjs)'); 55 | assert.equal(await esm1.dynamic.mts(), 'Hello', 'ESM.js :: dynamic :: import(mts)'); 56 | 57 | // Checking ".mjs" with ESM content 58 | assert.equal(typeof esm2, 'object', 'ESM.mjs :: typeof'); 59 | assert.equal(typeof esm2.hello, 'function', 'ESM.mjs :: typeof :: hello'); 60 | assert.equal(esm2.hello('you'), 'hello, you', 'ESM.mjs :: value :: hello'); 61 | 62 | // DYANMIC IMPORTS via MJS file 63 | assert.equal(typeof esm2.dynamic, 'object', 'ESM.mjs :: typeof :: dynamic'); 64 | assert.equal(await esm2.dynamic.cjs(), 'foo-bar', 'ESM.mjs :: dynamic :: import(cjs)'); 65 | assert.equal(await esm2.dynamic.cts(), 'foo-bar', 'ESM.mjs :: dynamic :: import(cts)'); 66 | assert.equal(await esm2.dynamic.mjs(), 'Hello', 'ESM.mjs :: dynamic :: import(mjs)'); 67 | assert.equal(await esm2.dynamic.mts(), 'Hello', 'ESM.mjs :: dynamic :: import(mts)'); 68 | 69 | console.log('DONE~!'); 70 | })(); 71 | -------------------------------------------------------------------------------- /test/config/tsm.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('tsm/config').Config} 3 | */ 4 | exports.config = { 5 | '.json': { 6 | loader: 'text', 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/defines.ts: -------------------------------------------------------------------------------- 1 | import { define } from 'tsm/config'; 2 | import type * as tsm from 'tsm/config'; 3 | 4 | let loaders: tsm.Loaders = { 5 | '.ts': 'ts', 6 | '.tsm': 'ts', 7 | '.tsx': 'tsx', 8 | }; 9 | 10 | let config: tsm.Config = { 11 | '.tsx': { 12 | loader: 'tsx', 13 | banner: 'import * as preact from "preact";' 14 | }, 15 | '.jsx': { 16 | loader: 'jsx', 17 | banner: 'import * as preact from "preact";' 18 | } 19 | }; 20 | 21 | let common: tsm.Options = { 22 | target: 'es2021', 23 | jsxFactory: 'preact.h', 24 | jsxFragment: 'preact.Fragment', 25 | }; 26 | 27 | // --- 28 | // INVALID: loaders + config 29 | // --- 30 | 31 | // @ts-expect-error 32 | define({ loaders, config }); 33 | // @ts-expect-error 34 | define({ common, loaders, config }); 35 | 36 | // --- 37 | // INVALID: config + extns 38 | // --- 39 | 40 | // @ts-expect-error 41 | define({ config, '.tsx': config['.tsx'] }); 42 | // @ts-expect-error 43 | define({ common, config, '.tsx': config['.tsx'] }); 44 | // @ts-expect-error 45 | define({ '.tsx': config['.tsx'], config }); 46 | 47 | // --- 48 | // INVALID: loaders + extns 49 | // --- 50 | 51 | // @ts-expect-error 52 | define({ loaders, '.tsx': config['.tsx'] }); 53 | // @ts-expect-error 54 | define({ common, loaders, '.tsx': config['.tsx'] }); 55 | // @ts-expect-error 56 | define({ '.tsx': config['.tsx'], loaders }); 57 | 58 | // --- 59 | // VALID 60 | // --- 61 | 62 | define({ loaders, common }); 63 | define({ common, loaders }); 64 | 65 | define({ config, common }); 66 | define({ common, config }); 67 | 68 | define({ common }); 69 | define({ loaders }); 70 | define({ config }); 71 | define(config); 72 | 73 | define({ 74 | common, 75 | '.tsx': { 76 | target: 'es2017' 77 | } 78 | }); 79 | 80 | define({ 81 | '.tsx': { 82 | target: 'es2017' 83 | } 84 | }); 85 | -------------------------------------------------------------------------------- /test/fixtures/App1.jsx: -------------------------------------------------------------------------------- 1 | import * as React from './mock'; 2 | 3 | /** 4 | * @typedef Props 5 | * @property {string} foo 6 | */ 7 | 8 | /** 9 | * @param {Props} props 10 | */ 11 | export default function App(props) { 12 | return
hello world
; 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/App2.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx React.createElement */ 2 | import * as React from './mock'; 3 | 4 | type Props = { 5 | foo: string; 6 | } 7 | 8 | export default function App(props: Props) { 9 | return
hello world
; 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": 123, 3 | "bar": 456 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/math.ts: -------------------------------------------------------------------------------- 1 | export const sum = (a: number, b: number) => a + b; 2 | export const div = (a: number, b: number) => a / b; 3 | export const mul = (a: number, b: number) => a * b; 4 | 5 | export const foobar = sum(1, 2); 6 | 7 | export const dynamic = { 8 | async cjs() { 9 | // @ts-ignore – tsc cant find type defs 10 | let m = await import('./utils.cjs'); 11 | return m.dashify('FooBar'); 12 | }, 13 | async cts() { 14 | // @ts-ignore – tsc doesnt like 15 | let m = await import('./utils.cts'); 16 | return m.dashify('FooBar'); 17 | }, 18 | async mjs() { 19 | // @ts-ignore – tsc cant find type defs 20 | let m = await import('./utils.mjs'); 21 | return m.capitalize('hello'); 22 | }, 23 | async mts() { 24 | // @ts-ignore – tsc doesnt like 25 | let m = await import('./utils.mts'); 26 | return m.capitalize('hello'); 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /test/fixtures/mock.ts: -------------------------------------------------------------------------------- 1 | export function createElement(tag: string, attr?: object, ...kids: any[]) { 2 | return { tag, attr, children: kids.length ? kids : null }; 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/module/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {string} name 3 | */ 4 | export function hello(name) { 5 | return `hello, ${name}`; 6 | } 7 | 8 | export const dynamic = { 9 | async cjs() { 10 | let m = await import('../utils.cjs'); 11 | return m.dashify('FooBar'); 12 | }, 13 | async cts() { 14 | // @ts-ignore – tsc doesnt like 15 | let m = await import('../utils.cts'); 16 | return m.dashify('FooBar'); 17 | }, 18 | async mjs() { 19 | let m = await import('../utils.mjs'); 20 | return m.capitalize('hello'); 21 | }, 22 | async mts() { 23 | // @ts-ignore – tsc doesnt like 24 | let m = await import('../utils.mts'); 25 | return m.capitalize('hello'); 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /test/fixtures/module/index.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {string} name 3 | */ 4 | export function hello(name) { 5 | return `hello, ${name}`; 6 | } 7 | 8 | export const dynamic = { 9 | async cjs() { 10 | let m = await import('../utils.cjs'); 11 | return m.dashify('FooBar'); 12 | }, 13 | async cts() { 14 | // @ts-ignore – tsc doesnt like 15 | let m = await import('../utils.cts'); 16 | return m.dashify('FooBar'); 17 | }, 18 | async mjs() { 19 | let m = await import('../utils.mjs'); 20 | return m.capitalize('hello'); 21 | }, 22 | async mts() { 23 | // @ts-ignore – tsc doesnt like 24 | let m = await import('../utils.mts'); 25 | return m.capitalize('hello'); 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /test/fixtures/module/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/utils.cts: -------------------------------------------------------------------------------- 1 | export function dashify(str: string): string { 2 | return str.replace(/([a-zA-Z])(?=[A-Z\d])/g, '$1-').toLowerCase(); 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/utils.mts: -------------------------------------------------------------------------------- 1 | export function capitalize(str: string): string { 2 | return str[0].toUpperCase() + str.substring(1); 3 | } 4 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const assert = require('assert'); 3 | 4 | const jsx = require('./fixtures/App1.jsx'); 5 | const json = require('./fixtures/data.json'); 6 | // @ts-ignore – prefers extensionless 7 | const tsx = require('./fixtures/App2.tsx'); 8 | // @ts-ignore – prefers extensionless 9 | const ts = require('./fixtures/math.ts'); 10 | // @ts-ignore – prefers extensionless 11 | const mts = require('./fixtures/utils.mts'); 12 | // @ts-ignore – prefers extensionless 13 | const cts = require('./fixtures/utils.cts'); 14 | // @ts-ignore – prefers extensionless 15 | const esm1 = require('./fixtures/module/index.js'); 16 | // @ts-ignore – prefers extensionless 17 | const esm2 = require('./fixtures/module/index.mjs'); 18 | 19 | const props = { 20 | foo: 'bar' 21 | }; 22 | 23 | const vnode = { 24 | tag: 'div', 25 | attr: { 26 | className: 'bar' 27 | }, 28 | children: ['hello world'] 29 | }; 30 | 31 | // NOTE: for CJS test runner 32 | (async function () { 33 | assert.ok(json != null, 'JSON :: load'); 34 | assert.equal(typeof json, 'object', 'JSON :: typeof'); 35 | assert.equal(json.foo, 123, 'JSON :: value'); 36 | 37 | assert.ok(jsx, 'JSX :: typeof'); 38 | assert.equal(typeof jsx, 'object', 'JSX :: typeof'); 39 | assert.equal(typeof jsx.default, 'function', 'JSX :: typeof :: default'); 40 | assert.deepEqual(jsx.default(props), vnode, 'JSX :: value'); 41 | 42 | assert.ok(tsx, 'TSX :: typeof'); 43 | assert.equal(typeof tsx, 'object', 'TSX :: typeof'); 44 | assert.equal(typeof tsx.default, 'function', 'TSX :: typeof :: default'); 45 | assert.deepEqual(tsx.default(props), vnode, 'TSX :: value'); 46 | 47 | assert.ok(ts, 'TS :: typeof'); 48 | assert.equal(typeof ts, 'object', 'TS :: typeof'); 49 | assert.equal(typeof ts.sum, 'function', 'TS :: typeof :: sum'); 50 | assert.equal(typeof ts.div, 'function', 'TS :: typeof :: div'); 51 | assert.equal(typeof ts.mul, 'function', 'TS :: typeof :: mul'); 52 | assert.equal(ts.foobar, 3, 'TS :: value :: foobar'); 53 | 54 | assert.ok(mts, 'MTS :: typeof'); 55 | assert.equal(typeof mts, 'object', 'MTS :: typeof'); 56 | assert.equal(typeof mts.capitalize, 'function', 'MTS :: typeof :: capitalize'); 57 | assert.equal(mts.capitalize('hello'), 'Hello', 'MTS :: value :: capitalize'); 58 | 59 | assert.ok(cts, 'CTS :: typeof'); 60 | assert.equal(typeof cts, 'object', 'CTS :: typeof'); 61 | assert.equal(typeof cts.dashify, 'function', 'CTS :: typeof :: dashify'); 62 | assert.equal(cts.dashify('FooBar'), 'foo-bar', 'CTS :: value :: dashify'); 63 | 64 | assert.ok(esm1, 'ESM.js :: typeof'); 65 | // Checking ".js" with ESM content 66 | assert.equal(typeof esm1, 'object', 'ESM.js :: typeof'); 67 | assert.equal(typeof esm1.hello, 'function', 'ESM.js :: typeof :: hello'); 68 | assert.equal(esm1.hello('you'), 'hello, you', 'ESM.js :: value :: hello'); 69 | 70 | // DYANMIC IMPORTS via JS file 71 | assert.equal(typeof esm1.dynamic, 'object', 'ESM.js :: typeof :: dynamic'); 72 | assert.equal(await esm1.dynamic.cjs(), 'foo-bar', 'ESM.js :: dynamic :: import(cjs)'); 73 | assert.equal(await esm1.dynamic.cts(), 'foo-bar', 'ESM.js :: dynamic :: import(cts)'); 74 | assert.equal(await esm1.dynamic.mjs(), 'Hello', 'ESM.js :: dynamic :: import(mjs)'); 75 | assert.equal(await esm1.dynamic.mts(), 'Hello', 'ESM.js :: dynamic :: import(mts)'); 76 | 77 | // Checking ".mjs" with ESM content 78 | assert.equal(typeof esm2, 'object', 'ESM.mjs :: typeof'); 79 | assert.equal(typeof esm2.hello, 'function', 'ESM.mjs :: typeof :: hello'); 80 | assert.equal(esm2.hello('you'), 'hello, you', 'ESM.mjs :: value :: hello'); 81 | 82 | // DYANMIC IMPORTS via MJS file 83 | assert.equal(typeof esm2.dynamic, 'object', 'ESM.mjs :: typeof :: dynamic'); 84 | assert.equal(await esm2.dynamic.cjs(), 'foo-bar', 'ESM.mjs :: dynamic :: import(cjs)'); 85 | assert.equal(await esm2.dynamic.cts(), 'foo-bar', 'ESM.mjs :: dynamic :: import(cts)'); 86 | assert.equal(await esm2.dynamic.mjs(), 'Hello', 'ESM.mjs :: dynamic :: import(mjs)'); 87 | assert.equal(await esm2.dynamic.mts(), 'Hello', 'ESM.mjs :: dynamic :: import(mts)'); 88 | 89 | console.log('DONE~!'); 90 | })(); 91 | -------------------------------------------------------------------------------- /test/index.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import * as assert from 'assert'; 3 | 4 | // @ts-ignore allowSyntheticDefaultImports 5 | import json from './fixtures/data.json'; 6 | 7 | import jsx from './fixtures/App1'; 8 | // @ts-ignore – tsc does not like 9 | import * as mts from './fixtures/utils.mts'; 10 | // @ts-ignore – tsc does not like 11 | import * as cts from './fixtures/utils.cts'; 12 | // @ts-ignore – prefers extensionless 13 | import * as ts from './fixtures/math.ts'; 14 | // @ts-ignore – prefers extensionless 15 | import tsx from './fixtures/App2.tsx'; 16 | 17 | import * as esm1 from './fixtures/module/index.js'; 18 | import * as esm2 from './fixtures/module/index.mjs'; 19 | 20 | const props = { 21 | foo: 'bar' 22 | }; 23 | 24 | const vnode = { 25 | tag: 'div', 26 | attr: { 27 | className: 'bar' 28 | }, 29 | children: ['hello world'] 30 | }; 31 | 32 | // Note: for Node 12.x tests 33 | (async function () { 34 | assert.ok(json != null, 'JSON :: load'); 35 | assert.equal(typeof json, 'object', 'JSON :: typeof'); 36 | assert.equal(json.foo, 123, 'JSON :: value'); 37 | 38 | // NOTE: no "default" key 39 | assert.ok(jsx, 'JSX :: typeof'); 40 | assert.equal(typeof jsx, 'function', 'JSX :: typeof'); 41 | assert.deepEqual(jsx(props), vnode, 'JSX :: value'); 42 | 43 | // NOTE: no "default" key 44 | assert.ok(tsx, 'TSX :: typeof'); 45 | assert.equal(typeof tsx, 'function', 'TSX :: typeof'); 46 | assert.deepEqual(tsx(props), vnode, 'TSX :: value'); 47 | 48 | assert.ok(ts, 'TS :: typeof'); 49 | assert.equal(typeof ts, 'object', 'TS :: typeof'); 50 | assert.equal(typeof ts.sum, 'function', 'TS :: typeof :: sum'); 51 | assert.equal(typeof ts.div, 'function', 'TS :: typeof :: div'); 52 | assert.equal(typeof ts.mul, 'function', 'TS :: typeof :: mul'); 53 | assert.equal(ts.foobar, 3, 'TS :: value :: foobar'); 54 | 55 | assert.equal(typeof mts, 'object', 'MTS :: typeof'); 56 | assert.equal(typeof mts.capitalize, 'function', 'MTS :: typeof :: capitalize'); 57 | assert.equal(mts.capitalize('hello'), 'Hello', 'MTS :: value :: capitalize'); 58 | 59 | assert.equal(typeof cts, 'object', 'CTS :: typeof'); 60 | assert.equal(typeof cts.dashify, 'function', 'CTS :: typeof :: dashify'); 61 | assert.equal(cts.dashify('FooBar'), 'foo-bar', 'CTS :: value :: dashify'); 62 | 63 | // Checking ".js" with ESM content (type: module) 64 | assert.equal(typeof esm1, 'object', 'ESM.js :: typeof'); 65 | assert.equal(typeof esm1.hello, 'function', 'ESM.js :: typeof :: hello'); 66 | assert.equal(esm1.hello('you'), 'hello, you', 'ESM.js :: value :: hello'); 67 | 68 | // DYANMIC IMPORTS via JS file 69 | assert.equal(typeof esm1.dynamic, 'object', 'ESM.js :: typeof :: dynamic'); 70 | assert.equal(await esm1.dynamic.cjs(), 'foo-bar', 'ESM.js :: dynamic :: import(cjs)'); 71 | assert.equal(await esm1.dynamic.cts(), 'foo-bar', 'ESM.js :: dynamic :: import(cts)'); 72 | assert.equal(await esm1.dynamic.mjs(), 'Hello', 'ESM.js :: dynamic :: import(mjs)'); 73 | assert.equal(await esm1.dynamic.mts(), 'Hello', 'ESM.js :: dynamic :: import(mts)'); 74 | 75 | // Checking ".mjs" with ESM content 76 | assert.equal(typeof esm2, 'object', 'ESM.mjs :: typeof'); 77 | assert.equal(typeof esm2.hello, 'function', 'ESM.mjs :: typeof :: hello'); 78 | assert.equal(esm2.hello('you'), 'hello, you', 'ESM.mjs :: value :: hello'); 79 | 80 | // DYANMIC IMPORTS via MJS file 81 | assert.equal(typeof esm2.dynamic, 'object', 'ESM.mjs :: typeof :: dynamic'); 82 | assert.equal(await esm2.dynamic.cjs(), 'foo-bar', 'ESM.mjs :: dynamic :: import(cjs)'); 83 | assert.equal(await esm2.dynamic.cts(), 'foo-bar', 'ESM.mjs :: dynamic :: import(cts)'); 84 | assert.equal(await esm2.dynamic.mjs(), 'Hello', 'ESM.mjs :: dynamic :: import(mjs)'); 85 | assert.equal(await esm2.dynamic.mts(), 'Hello', 'ESM.mjs :: dynamic :: import(mts)'); 86 | 87 | console.log('DONE~!'); 88 | })(); 89 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "allowJs": true, 5 | "lib": ["es2019"], 6 | "target": "es2019", 7 | "moduleResolution": "node", 8 | "strictBindCallApply": true, 9 | "allowSyntheticDefaultImports": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "strictFunctionTypes": true, 12 | "resolveJsonModule": true, 13 | "strictNullChecks": true, 14 | "noImplicitThis": true, 15 | "noImplicitAny": true, 16 | "alwaysStrict": true, 17 | "module": "esnext", 18 | "checkJs": true, 19 | "noEmit": true, 20 | "jsx": "react", 21 | "paths": { 22 | "tsm/config": ["./config/index.d.ts"] 23 | } 24 | }, 25 | "include": [ 26 | "build", 27 | "test", 28 | "src", 29 | ], 30 | "exclude": [ 31 | "node_modules" 32 | ] 33 | } 34 | --------------------------------------------------------------------------------