Skip to content

Commit

Permalink
feat: add typescript support to the esbuild plugin (#431)
Browse files Browse the repository at this point in the history
  • Loading branch information
kyle-johnson authored Jul 31, 2024
1 parent 85adaf4 commit afa4382
Show file tree
Hide file tree
Showing 11 changed files with 1,105 additions and 1,227 deletions.
5 changes: 5 additions & 0 deletions .changeset/fifty-buses-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@optimize-lodash/esbuild-plugin": major
---

Add experimental Typescript support. Thanks @ldu1020 (#428)!
5 changes: 5 additions & 0 deletions .changeset/perfect-beers-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@optimize-lodash/transform": patch
---

Bump acorn dev dependency, required for esbuild plugin changes.
2 changes: 2 additions & 0 deletions packages/esbuild-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ There are [multiple](https://github.com/webpack/webpack/issues/6925) [issues](ht

There is also an option to use [lodash-es](https://www.npmjs.com/package/lodash-es) for projects which ship CommonJS and ES builds: the ES build will be transformed to import from `lodash-es`.

Versions of this plugin _before_ 3.x did not support Typescript. 3.x and later support Typescript, although Typescript support is considered experimental.

### This input

```javascript
Expand Down
10 changes: 6 additions & 4 deletions packages/esbuild-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
"testTimeout": 10000
},
"peerDependencies": {
"esbuild": ">= 0.8.0"
"esbuild": ">= 0.8.47",
"acorn-typescript": "^1.4.13"
},
"devDependencies": {
"@tsconfig/node16": "16.1.3",
Expand All @@ -54,8 +55,9 @@
"@types/node": "16.11.7",
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"acorn-typescript": "1.4.13",
"depcheck": "1.4.6",
"esbuild": "0.15.16",
"esbuild": "0.23.0",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-jest": "28.6.0",
Expand All @@ -67,7 +69,7 @@
"typescript": "5.5.4"
},
"dependencies": {
"@optimize-lodash/transform": "workspace:3.0.3",
"acorn": "8.x"
"@optimize-lodash/transform": "workspace:*",
"acorn": "~8.12.1"
}
}
107 changes: 80 additions & 27 deletions packages/esbuild-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,56 @@
import fs from "fs";

import * as acorn from "acorn";
import { Plugin } from "esbuild";
import { Loader, Plugin } from "esbuild";
import {
ParseFunction,
transform,
UNCHANGED,
} from "@optimize-lodash/transform";
import { tsPlugin } from "acorn-typescript";

const wrappedParse: ParseFunction = (code) =>
acorn.parse(code, { ecmaVersion: "latest", sourceType: "module" });
acorn.parse(code, {
ecmaVersion: "latest",
sourceType: "module",
locations: true,
});

const wrappedTypescriptParse: ParseFunction = (code) =>
// @ts-expect-error type issues, probably due to differing acorn versions
acorn.Parser.extend(tsPlugin()).parse(code, {
ecmaVersion: "latest",
sourceType: "module",
locations: true,
});

const selectLoader = (path: string): Loader => {
const extension = path.split(".").at(-1);
switch (extension) {
case "mts":
case "ts": {
return "ts";
}
case "tsx": {
return "tsx";
}
case "mjs":
case "js": {
return "js";
}
case "jsx": {
return "jsx";
}
default: {
throw new Error(`Unexpected extension: ${extension}`);
}
}
};

// eslint-disable-next-line unicorn/prefer-set-has -- faster than Set for a small number of items
const TS_EXTENSIONS = ["ts", "tsx", "mts"];
const isTypescriptPath = (path: string): boolean =>
TS_EXTENSIONS.includes(path.split(".").at(-1)!);

export type PluginOptions = {
useLodashEs?: true;
Expand All @@ -23,36 +64,48 @@ export function lodashOptimizeImports({
}: PluginOptions = {}): Plugin {
const cache = new Map<
string,
{ input: string; output: string | UNCHANGED }
| { input: string; output: string; loader: Loader }
| { input: string; output: UNCHANGED; loader?: never }
>();

return {
name: "lodash-optimize-imports",
setup(build) {
build.onLoad({ filter: /.(js|ts|jsx|tsx)$/ }, async ({ path }) => {
const input = await fs.promises.readFile(path, "utf8");
const cached = cache.get(path); // TODO: unit test the cache

if (cached && input === cached.input && cached.output === UNCHANGED) {
return;
}

const result = transform({
code: input,
id: path,
parse: wrappedParse,
useLodashEs,
appendDotJs,
});
if (result === UNCHANGED) {
cache.set(path, { input, output: UNCHANGED });
return;
}

const output = result.code;
cache.set(path, { input, output });
return { contents: output };
});
build.onLoad(
{ filter: /\.(mjs|mts|js|ts|jsx|tsx)$/ },
async ({ path }) => {
const input = await fs.promises.readFile(path, "utf8");
const cached = cache.get(path); // TODO: unit test the cache

// the input hasn't changed since we last processed it + we have a cached result
if (cached && input === cached.input) {
return cached.output === UNCHANGED
? undefined
: { contents: cached.output, loader: cached.loader };
}

const result = transform({
code: input,
id: path,
// acorn is far more battle-tested than acorn-typescript,
// so we only use acorn-typescript when we have to
parse: isTypescriptPath(path)
? wrappedTypescriptParse
: wrappedParse,
useLodashEs,
appendDotJs,
});
if (result === UNCHANGED) {
cache.set(path, { input, output: UNCHANGED });
return;
}

const output = result.code;
const loader = selectLoader(path);
cache.set(path, { input, output, loader });
return { contents: output, loader };
},
);
},
};
}
92 changes: 92 additions & 0 deletions packages/esbuild-plugin/tests/__snapshots__/esbuild.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,36 @@ function hello(name) {
"
`;
exports[`esbuild sanity check no-transform.ts 1`] = `
""use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var no_transform_exports = {};
__export(no_transform_exports, {
hello: () => hello
});
module.exports = __toCommonJS(no_transform_exports);
function hello(name) {
return \`Hello \${name != null ? name : "World"}!\`;
}
"
`;
exports[`esbuild with lodashOptimizeImports() CJS: standard-and-fp.js 1`] = `
""use strict";
var __create = Object.create;
Expand All @@ -51,6 +81,54 @@ var __copyProps = (to, from, except, desc) => {
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var standard_and_fp_exports = {};
__export(standard_and_fp_exports, {
isNonNilArray: () => isNonNilArray
});
module.exports = __toCommonJS(standard_and_fp_exports);
var import_isNil = __toESM(require("lodash/isNil.js"));
var import_negate = __toESM(require("lodash/negate.js"));
var import_every = __toESM(require("lodash/fp/every.js"));
const everyNonNil = (0, import_every.default)((0, import_negate.default)(import_isNil.default));
function isNonNilArray(input) {
return Array.isArray(input) && everyNonNil(input);
}
"
`;
exports[`esbuild with lodashOptimizeImports() CJS: standard-and-fp.ts 1`] = `
""use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
Expand Down Expand Up @@ -83,3 +161,17 @@ export {
};
"
`;
exports[`esbuild with lodashOptimizeImports() ESM: standard-and-fp.ts 1`] = `
"import { isNil } from "lodash-es";
import { negate } from "lodash-es";
import { every } from "lodash-es/fp";
const everyNonNil = every(negate(isNil));
function isNonNilArray(input) {
return Array.isArray(input) && everyNonNil(input);
}
export {
isNonNilArray
};
"
`;
Loading

0 comments on commit afa4382

Please sign in to comment.