From c93553706dcd95fa9c57e5b7499e0229c0826851 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Tue, 5 Oct 2021 09:05:06 -0700 Subject: [PATCH 1/3] feat: Allow JavaScript extensions for TypeScript imports (#2) * feat(loader): map js -> ts when source is ts * feat(require): map js -> ts when source is ts * fix(require): work after minify --- src/loader.ts | 43 ++++++++++++++++++++++++++++++++++++++----- src/require.ts | 41 ++++++++++++++++++++++++++++++++++++++++- src/utils.ts | 1 - test/config/index.ts | 10 ++++++++++ 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/src/loader.ts b/src/loader.ts index e29c65a..1880533 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -2,7 +2,7 @@ import * as url from 'url'; import { existsSync } from 'fs'; import * as tsm from './utils.js'; -import type { Config, Options } from 'tsm/config'; +import type { Config, Extension, Options } from 'tsm/config'; type TSM = typeof import('./utils.d'); let config: Config; @@ -43,26 +43,59 @@ async function load(): Promise { } const EXTN = /\.\w+(?=\?|$)/; +const isTS = /\.[mc]?tsx?(?=\?|$)/; +const isJS = /\.([mc])?js$/; async function toOptions(uri: string): Promise { config = config || await load(); let [extn] = EXTN.exec(uri) || []; return config[extn as `.${string}`]; } +function check(fileurl: string): string | void { + let tmp = url.fileURLToPath(fileurl); + if (existsSync(tmp)) return fileurl; +} + const root = url.pathToFileURL(process.cwd() + '/'); export const resolve: Resolve = async function (ident, context, fallback) { // ignore "prefix:" and non-relative identifiers if (/^\w+\:?/.test(ident)) return fallback(ident, context, fallback); + let match: RegExpExecArray | null; + let idx: number, ext: Extension, path: string | void; let output = new url.URL(ident, context.parentURL || root); - if (EXTN.test(output.pathname)) return { url: output.href }; + + // source ident includes extension + if (match = EXTN.exec(output.href)) { + ext = match[0] as Extension; + if (!context.parentURL || isTS.test(ext)) { + return { url: output.href }; + } + // source ident exists + path = check(output.href); + if (path) return { url: path }; + // parent importer is a ts file + // source ident is js & NOT exists + if (isJS.test(ext) && isTS.test(context.parentURL)) { + // reconstruct ".js" -> ".ts" source file + path = output.href.substring(0, idx = match.index); + if (path = check(path + ext.replace('js', 'ts'))) { + idx += ext.length; + if (idx > output.href.length) { + path += output.href.substring(idx); + } + return { url: path }; + } + // return original, let it error + return fallback(ident, context, fallback); + } + } config = config || await load(); - let tmp, ext, path; for (ext in config) { - path = url.fileURLToPath(tmp = output.href + ext); - if (existsSync(path)) return { url: tmp }; + path = check(output.href + ext); + if (path) return { url: path }; } return fallback(ident, context, fallback); diff --git a/src/require.ts b/src/require.ts index 7f74ebb..7593e75 100644 --- a/src/require.ts +++ b/src/require.ts @@ -15,6 +15,40 @@ let env = (tsm as TSM).$defaults('cjs'); let uconf = env.file && require(env.file); let config: Config = (tsm as TSM).$finalize(env, uconf); +declare const $$req: NodeJS.Require; +const tsrequire = 'var $$req=require;require=(' + function () { + let { existsSync } = $$req('fs'); + let { URL, pathToFileURL } = $$req('url'); + + return new Proxy($$req, { + // NOTE: only here if source is TS + apply(req, ctx, args: [id: string]) { + let [ident] = args; + if (!ident) return req.apply(ctx || $$req, args); + + // ignore "prefix:" and non-relative identifiers + if (/^\w+\:?/.test(ident)) return $$req(ident); + + // exit early if no extension provided + let match = /\.([mc])?js(?=\?|$)/.exec(ident); + if (match == null) return $$req(ident); + + let base = pathToFileURL(__filename) as import('url').URL; + let file = new URL(ident, base).pathname as string; + if (existsSync(file)) return $$req(ident); + + // ?js -> ?ts file + file = file.replace( + new RegExp(match[0] + '$'), + match[0].replace('js', 'ts') + ); + + // return the new "[mc]ts" file, or let error + return existsSync(file) ? $$req(file) : $$req(ident); + } + }) +} + ')();' + function loader(Module: Module, sourcefile: string) { let extn = extname(sourcefile); let pitch = Module._compile!.bind(Module); @@ -23,8 +57,13 @@ function loader(Module: Module, sourcefile: string) { let options = config[extn]; if (options == null) return pitch(source, sourcefile); + let banner = options.banner || ''; + if (/\.[mc]?tsx?$/.test(extn)) { + banner = tsrequire + banner; + } + esbuild = esbuild || require('esbuild'); - let result = esbuild.transformSync(source, { ...options, sourcefile }); + let result = esbuild.transformSync(source, { ...options, banner, sourcefile }); return pitch(result.code, sourcefile); }; diff --git a/src/utils.ts b/src/utils.ts index 8e957d1..bd76880 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,7 +5,6 @@ import type { Format } from 'esbuild'; import type * as tsm from 'tsm/config'; import type { Defaults } from './utils.d'; - exports.$defaults = function (format: Format): Defaults { let { FORCE_COLOR, NO_COLOR, NODE_DISABLE_COLORS, TERM } = process.env; diff --git a/test/config/index.ts b/test/config/index.ts index 5402eb5..781fccc 100644 --- a/test/config/index.ts +++ b/test/config/index.ts @@ -1,5 +1,8 @@ import * as assert from 'assert'; +// NOTE: doesn't actually exist yet +import * as js from '../fixtures/math.js'; + // NOTE: avoid need for syntheticDefault + analysis import * as data from '../fixtures/data.json'; assert.equal(typeof data, 'object'); @@ -7,4 +10,11 @@ assert.equal(typeof data, 'object'); // @ts-ignore - generally doesn't exist assert.equal(typeof data.default, 'string'); +// NOTE: raw JS missing +assert.equal(typeof js, 'object', 'JS :: typeof'); +assert.equal(typeof js.sum, 'function', 'JS :: typeof :: sum'); +assert.equal(typeof js.div, 'function', 'JS :: typeof :: div'); +assert.equal(typeof js.mul, 'function', 'JS :: typeof :: mul'); +assert.equal(js.foobar, 3, 'JS :: value :: foobar'); + console.log('DONE~!'); From cc7ce9883312ee643bed61ef947b8f75bffab7a3 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Tue, 5 Oct 2021 09:16:09 -0700 Subject: [PATCH 2/3] feat: support ".mts" and ".cts" extensions (#3) * chore: bump esbuild version * feat: add ".cts" and ".mts" as default extns * chore: add "mts" and "cts" fixtures; - typescript file uses "mjs" and "cjs" imports - javascript files use real "mts" and "cts" imports * chore(ci): also run typescript test w/ --require hook --- .github/workflows/ci.yml | 7 +++++-- docs/configuration.md | 2 ++ package.json | 2 +- src/utils.ts | 2 ++ test/config/index.ts | 14 ++++++++++++++ test/fixtures/utils.cts | 3 +++ test/fixtures/utils.mts | 3 +++ test/index.js | 22 +++++++++++++++++++--- test/index.mjs | 18 ++++++++++++++++-- 9 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 test/fixtures/utils.cts create mode 100644 test/fixtures/utils.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d255b9..6e596d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,11 @@ jobs: - name: Tests <~ ESM run: node --loader ./loader.mjs test/index.mjs + - name: Tests <~ ESM <~ TypeScript + run: node --loader ./loader.mjs test/config/index.ts --tsmconfig test/config/tsm.js + - name: Tests <~ CommonJS run: node -r ./require.js test/index.js - - name: Tests <~ TS w/ Config - run: node --loader ./loader.mjs test/config/index.ts --tsmconfig test/config/tsm.js + - name: Tests <~ CommonJS <~ TypeScript + run: node -r ./require.js test/config/index.ts --tsmconfig test/config/tsm.js diff --git a/docs/configuration.md b/docs/configuration.md index fb717d3..d9edf84 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -29,6 +29,8 @@ Additionally, tsm defines a few extensions by default, each of which is assigned let config = { '.jsx': { ...options, loader: 'jsx' }, '.tsx': { ...options, loader: 'tsx' }, + '.mts': { ...options, loader: 'ts' }, + '.cts': { ...options, loader: 'ts' }, '.ts': { ...options, loader: 'ts' }, } ``` diff --git a/package.json b/package.json index b72a604..f46a955 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "types": "tsc" }, "dependencies": { - "esbuild": "^0.13.3" + "esbuild": "^0.13.4" }, "devDependencies": { "@types/node": "16.10.2", diff --git a/src/utils.ts b/src/utils.ts index bd76880..349a6c0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -43,8 +43,10 @@ exports.$finalize = function (env: Defaults, custom?: tsm.ConfigFile): tsm.Confi } let config: tsm.Config = { + '.mts': { ...base, loader: 'ts' }, '.jsx': { ...base, loader: 'jsx' }, '.tsx': { ...base, loader: 'tsx' }, + '.cts': { ...base, loader: 'ts' }, '.ts': { ...base, loader: 'ts' }, }; diff --git a/test/config/index.ts b/test/config/index.ts index 781fccc..c0d4f70 100644 --- a/test/config/index.ts +++ b/test/config/index.ts @@ -2,6 +2,10 @@ import * as assert from 'assert'; // NOTE: doesn't actually exist yet import * as js from '../fixtures/math.js'; +// @ts-ignore - cannot find types +import * as mjs from '../fixtures/utils.mjs'; +// @ts-ignore - cannot find types +import * as cjs from '../fixtures/utils.cjs'; // NOTE: avoid need for syntheticDefault + analysis import * as data from '../fixtures/data.json'; @@ -17,4 +21,14 @@ assert.equal(typeof js.div, 'function', 'JS :: typeof :: div'); assert.equal(typeof js.mul, 'function', 'JS :: typeof :: mul'); assert.equal(js.foobar, 3, 'JS :: value :: foobar'); +// NOTE: raw MJS missing +assert.equal(typeof mjs, 'object', 'MJS :: typeof'); +assert.equal(typeof mjs.capitalize, 'function', 'MJS :: typeof :: capitalize'); +assert.equal(mjs.capitalize('hello'), 'Hello', 'MJS :: value :: capitalize'); + +// NOTE: raw CJS missing +assert.equal(typeof cjs, 'object', 'CJS :: typeof'); +assert.equal(typeof cjs.dashify, 'function', 'CJS :: typeof :: dashify'); +assert.equal(cjs.dashify('FooBar'), 'foo-bar', 'CJS :: value :: dashify'); + console.log('DONE~!'); diff --git a/test/fixtures/utils.cts b/test/fixtures/utils.cts new file mode 100644 index 0000000..f265d94 --- /dev/null +++ b/test/fixtures/utils.cts @@ -0,0 +1,3 @@ +export function dashify(str: string): string { + return str.replace(/([a-zA-Z])(?=[A-Z\d])/g, '$1-').toLowerCase(); +} diff --git a/test/fixtures/utils.mts b/test/fixtures/utils.mts new file mode 100644 index 0000000..11db639 --- /dev/null +++ b/test/fixtures/utils.mts @@ -0,0 +1,3 @@ +export function capitalize(str: string): string { + return str[0].toUpperCase() + str.substring(1); +} diff --git a/test/index.js b/test/index.js index d484742..abbb89c 100644 --- a/test/index.js +++ b/test/index.js @@ -1,10 +1,16 @@ // @ts-check const assert = require('assert'); -const jsx = require('./fixtures/App1'); +const jsx = require('./fixtures/App1.jsx'); const json = require('./fixtures/data.json'); -const tsx = require('./fixtures/App2'); -const ts = require('./fixtures/math'); +// @ts-ignore – prefers extensionless +const tsx = require('./fixtures/App2.tsx'); +// @ts-ignore – prefers extensionless +const ts = require('./fixtures/math.ts'); +// @ts-ignore – prefers extensionless +const mts = require('./fixtures/utils.mts'); +// @ts-ignore – prefers extensionless +const cts = require('./fixtures/utils.cts'); const props = { foo: 'bar' @@ -39,4 +45,14 @@ assert.equal(typeof ts.div, 'function', 'TS :: typeof :: div'); assert.equal(typeof ts.mul, 'function', 'TS :: typeof :: mul'); assert.equal(ts.foobar, 3, 'TS :: value :: foobar'); +assert(mts, 'MTS :: typeof'); +assert.equal(typeof mts, 'object', 'MTS :: typeof'); +assert.equal(typeof mts.capitalize, 'function', 'MTS :: typeof :: capitalize'); +assert.equal(mts.capitalize('hello'), 'Hello', 'MTS :: value :: capitalize'); + +assert(cts, 'CTS :: typeof'); +assert.equal(typeof cts, 'object', 'CTS :: typeof'); +assert.equal(typeof cts.dashify, 'function', 'CTS :: typeof :: dashify'); +assert.equal(cts.dashify('FooBar'), 'foo-bar', 'CTS :: value :: dashify'); + console.log('DONE~!'); diff --git a/test/index.mjs b/test/index.mjs index 57042e5..cf7a521 100644 --- a/test/index.mjs +++ b/test/index.mjs @@ -3,8 +3,14 @@ import assert from 'assert'; import jsx from './fixtures/App1'; import json from './fixtures/data.json'; -import * as ts from './fixtures/math'; -import tsx from './fixtures/App2'; +// @ts-ignore – expects definitions +import * as mts from './fixtures/utils.mts'; +// @ts-ignore – expects definitions +import * as cts from './fixtures/utils.cts'; +// @ts-ignore – prefers extensionless +import * as ts from './fixtures/math.ts'; +// @ts-ignore – prefers extensionless +import tsx from './fixtures/App2.tsx'; const props = { foo: 'bar' @@ -39,4 +45,12 @@ assert.equal(typeof ts.div, 'function', 'TS :: typeof :: div'); assert.equal(typeof ts.mul, 'function', 'TS :: typeof :: mul'); assert.equal(ts.foobar, 3, 'TS :: value :: foobar'); +assert.equal(typeof mts, 'object', 'MTS :: typeof'); +assert.equal(typeof mts.capitalize, 'function', 'MTS :: typeof :: capitalize'); +assert.equal(mts.capitalize('hello'), 'Hello', 'MTS :: value :: capitalize'); + +assert.equal(typeof cts, 'object', 'CTS :: typeof'); +assert.equal(typeof cts.dashify, 'function', 'CTS :: typeof :: dashify'); +assert.equal(cts.dashify('FooBar'), 'foo-bar', 'CTS :: value :: dashify'); + console.log('DONE~!'); From 9ab86f035f251a36ffaf22e1cf7b558bf819d3fd Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Tue, 5 Oct 2021 09:35:24 -0700 Subject: [PATCH 3/3] 2.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f46a955..283cdff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tsm", - "version": "2.0.0", + "version": "2.1.0", "repository": "lukeed/tsm", "description": "TypeScript Module Loader", "license": "MIT",