├── pnpm-workspace.yaml ├── packages ├── esbuild-plugin │ ├── .depcheckrc │ ├── .prettierignore │ ├── .eslintignore │ ├── tests │ │ ├── fixtures │ │ │ ├── no-transform.js │ │ │ └── standard-and-fp.js │ │ ├── esbuild.test.ts │ │ └── __snapshots__ │ │ │ └── esbuild.test.ts.snap │ ├── tsconfig.json │ ├── tsconfig.dist.json │ ├── .eslintrc.js │ ├── LICENSE │ ├── src │ │ └── index.ts │ ├── package.json │ └── README.md ├── rollup-plugin │ ├── .depcheckrc │ ├── .prettierignore │ ├── .eslintignore │ ├── tsconfig.json │ ├── tests │ │ ├── fixtures │ │ │ └── standard-and-fp.js │ │ ├── __snapshots__ │ │ │ └── rollup.test.ts.snap │ │ ├── bundle-size.test.ts │ │ ├── rollup.test.ts │ │ └── test.ts │ ├── tsconfig.dist.json │ ├── .eslintrc.js │ ├── LICENSE │ ├── package.json │ ├── src │ │ └── index.ts │ └── README.md └── transform │ ├── .depcheckrc │ ├── .prettierignore │ ├── .eslintignore │ ├── tsconfig.json │ ├── tsconfig.dist.json │ ├── src │ ├── guards.ts │ ├── lodash-specifiers-to-cjs.ts │ ├── lodash-specifiers-to-es.ts │ └── index.ts │ ├── .eslintrc.js │ ├── README.md │ ├── LICENSE │ ├── package.json │ └── tests │ └── test.ts ├── package.json ├── .changeset ├── config.json └── README.md ├── renovate.json ├── README.md ├── .gitignore └── .github └── workflows └── main.yml /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/**' 3 | -------------------------------------------------------------------------------- /packages/esbuild-plugin/.depcheckrc: -------------------------------------------------------------------------------- 1 | ignores: ["@types/*", "@tsconfig/*", "depcheck"] 2 | -------------------------------------------------------------------------------- /packages/rollup-plugin/.depcheckrc: -------------------------------------------------------------------------------- 1 | ignores: ["@types/*", "@tsconfig/*", "depcheck"] 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@changesets/cli": "2.17.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/transform/.depcheckrc: -------------------------------------------------------------------------------- 1 | ignores: ["@types/*", "@tsconfig/*", "estree", "depcheck"] 2 | -------------------------------------------------------------------------------- /packages/rollup-plugin/.prettierignore: -------------------------------------------------------------------------------- 1 | tests/fixtures 2 | dist/ 3 | coverage/ 4 | CHANGELOG.md 5 | -------------------------------------------------------------------------------- /packages/transform/.prettierignore: -------------------------------------------------------------------------------- 1 | tests/fixtures 2 | dist/ 3 | coverage/ 4 | CHANGELOG.md 5 | -------------------------------------------------------------------------------- /packages/esbuild-plugin/.prettierignore: -------------------------------------------------------------------------------- 1 | tests/fixtures 2 | dist/ 3 | coverage/ 4 | CHANGELOG.md 5 | -------------------------------------------------------------------------------- /packages/transform/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .eslintrc.js 4 | tests/fixtures 5 | coverage 6 | -------------------------------------------------------------------------------- /packages/esbuild-plugin/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .eslintrc.js 4 | tests/fixtures 5 | coverage 6 | -------------------------------------------------------------------------------- /packages/rollup-plugin/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .eslintrc.js 4 | tests/fixtures 5 | coverage 6 | -------------------------------------------------------------------------------- /packages/esbuild-plugin/tests/fixtures/no-transform.js: -------------------------------------------------------------------------------- 1 | export function hello(name) { 2 | return `Hello ${name ?? "World"}!`; 3 | } 4 | -------------------------------------------------------------------------------- /packages/transform/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node12/tsconfig.json", 3 | 4 | "include": ["src/**/*", "tests/*"], 5 | "exclude": ["node_modules", "dist", "tests/fixtures"] 6 | } 7 | -------------------------------------------------------------------------------- /packages/esbuild-plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node12/tsconfig.json", 3 | 4 | "include": ["src/**/*", "tests/*"], 5 | "exclude": ["node_modules", "dist", "tests/fixtures"] 6 | } 7 | -------------------------------------------------------------------------------- /packages/rollup-plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node12/tsconfig.json", 3 | 4 | "include": ["src/**/*", "tests/*"], 5 | "exclude": ["node_modules", "dist", "tests/fixtures"] 6 | } 7 | -------------------------------------------------------------------------------- /packages/esbuild-plugin/tests/fixtures/standard-and-fp.js: -------------------------------------------------------------------------------- 1 | import { isNil, negate } from "lodash"; 2 | import { every } from "lodash/fp"; 3 | 4 | const everyNonNil = every(negate(isNil)); 5 | 6 | export function isNonNilArray(input) { 7 | return Array.isArray(input) && everyNonNil(input); 8 | } 9 | -------------------------------------------------------------------------------- /packages/rollup-plugin/tests/fixtures/standard-and-fp.js: -------------------------------------------------------------------------------- 1 | import { isNil, negate } from "lodash"; 2 | import { every } from "lodash/fp"; 3 | 4 | const everyNonNil = every(negate(isNil)); 5 | 6 | export function isNonNilArray(input) { 7 | return Array.isArray(input) && everyNonNil(input); 8 | } 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "linked": [], 6 | "access": "restricted", 7 | "baseBranch": "main", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } 11 | -------------------------------------------------------------------------------- /packages/transform/tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | 4 | "compilerOptions": { 5 | "outDir": "dist", 6 | "sourceMap": true, 7 | "declaration": true, 8 | "declarationMap": true 9 | }, 10 | 11 | // simply excludes tests 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules", "dist", "tests"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/esbuild-plugin/tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | 4 | "compilerOptions": { 5 | "outDir": "dist", 6 | "sourceMap": true, 7 | "declaration": true, 8 | "declarationMap": true 9 | }, 10 | 11 | // simply excludes tests 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules", "dist", "tests"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/rollup-plugin/tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | 4 | "compilerOptions": { 5 | "outDir": "dist", 6 | "sourceMap": true, 7 | "declaration": true, 8 | "declarationMap": true 9 | }, 10 | 11 | // simply excludes tests 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules", "dist", "tests"] 14 | } 15 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /packages/transform/src/guards.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BaseNode, 3 | ImportDeclaration, 4 | ImportSpecifier, 5 | Program, 6 | } from "estree"; 7 | 8 | export function isImportDeclaration(node: BaseNode): node is ImportDeclaration { 9 | return node.type === "ImportDeclaration"; 10 | } 11 | 12 | export function isProgram(node: BaseNode): node is Program { 13 | return node.type === "Program"; 14 | } 15 | 16 | export function isImportSpecifierArray( 17 | items: ImportDeclaration["specifiers"] 18 | ): items is Array { 19 | return items.every((item) => item.type === "ImportSpecifier"); 20 | } 21 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":pinOnlyDevDependencies", 5 | ":dependencyDashboard", 6 | ":prHourlyLimit4", 7 | ":automergeLinters", 8 | ":automergeTesters", 9 | ":automergePr", 10 | ":automergePatch", 11 | ":automergeRequireAllStatusChecks", 12 | ":semanticCommits" 13 | ], 14 | "prConcurrentLimit": 5, 15 | "stabilityDays": 5, 16 | "packageRules": [ 17 | { 18 | "matchPackageNames": ["estree-walker"], 19 | "allowedVersions": "< 3.0.0" 20 | }, 21 | { 22 | "matchPackageNames": ["@types/node"], 23 | "extends": [":disableMajorUpdates"] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/rollup-plugin/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | tsconfigRootDir: __dirname, 6 | project: ["./tsconfig.json"], 7 | }, 8 | plugins: ["@typescript-eslint", "jest"], 9 | extends: [ 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 13 | "plugin:unicorn/recommended", 14 | "plugin:jest/recommended", 15 | "plugin:jest/style", 16 | "prettier", 17 | ], 18 | rules: { 19 | "unicorn/no-null": "off", 20 | "unicorn/prefer-module": "off", 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /packages/transform/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | tsconfigRootDir: __dirname, 6 | project: ["./tsconfig.json"], 7 | }, 8 | plugins: ["@typescript-eslint", "jest"], 9 | extends: [ 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 13 | "plugin:unicorn/recommended", 14 | "plugin:jest/recommended", 15 | "plugin:jest/style", 16 | "prettier", 17 | ], 18 | rules: { 19 | "unicorn/no-null": "off", 20 | "jest/no-conditional-expect": "off", 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /packages/transform/src/lodash-specifiers-to-cjs.ts: -------------------------------------------------------------------------------- 1 | import type { ImportSpecifier } from "estree"; 2 | 3 | /** 4 | * Turns a generic lodash import into a specific import using the CommonJS 5 | * lodash package. 6 | * 7 | * @param base "lodash" or "lodash/fp" 8 | * @param specifiers from an AST; assumes they are all ImportSpecifiers 9 | */ 10 | export function lodashSpecifiersToCjs( 11 | base: string, 12 | specifiers: Array 13 | ): Array { 14 | return specifiers.map( 15 | ({ imported, local }) => 16 | `import ${ 17 | imported.name !== local.name ? local.name : imported.name 18 | } from "${base}/${imported.name}";` 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /packages/esbuild-plugin/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | tsconfigRootDir: __dirname, 6 | project: ["./tsconfig.json"], 7 | }, 8 | plugins: ["@typescript-eslint", "jest"], 9 | extends: [ 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 13 | "plugin:unicorn/recommended", 14 | "plugin:jest/recommended", 15 | "plugin:jest/style", 16 | "prettier", 17 | ], 18 | rules: { 19 | "unicorn/no-null": "off", 20 | "unicorn/prefer-module": "off", 21 | "unicorn/prefer-node-protocol": "off", 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /packages/transform/src/lodash-specifiers-to-es.ts: -------------------------------------------------------------------------------- 1 | import type { ImportSpecifier } from "estree"; 2 | 3 | /** 4 | * Turns a generic lodash import into a specific import referencing the "lodash-es" 5 | * pacakge. Note that lodash-es cannot be imported from CommonJS. 6 | * 7 | * @param base "lodash" or "lodash/fp" 8 | * @param specifiers from an AST; assumes they are all ImportSpecifiers 9 | */ 10 | export function lodashSpecifiersToEs( 11 | base: string, 12 | specifiers: Array 13 | ): Array { 14 | const isFp = base.endsWith("fp"); 15 | return specifiers.map( 16 | ({ imported, local }) => 17 | `import { ${ 18 | imported.name !== local.name 19 | ? imported.name + " as " + local.name 20 | : local.name 21 | } } from "lodash-es${isFp ? "/fp" : ""}";` 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /packages/transform/README.md: -------------------------------------------------------------------------------- 1 | # Optimize `lodash` imports 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/kyle-johnson/rollup-plugin-optimize-lodash-imports/CI)](https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports/actions) 4 | ![LGTM Grade](https://img.shields.io/lgtm/grade/javascript/github/kyle-johnson/rollup-plugin-optimize-lodash-imports) 5 | [![license](https://img.shields.io/npm/l/@optimize-lodash/transform)](https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports/blob/main/packages/transform/LICENSE) 6 | [![Codecov](https://img.shields.io/codecov/c/github/kyle-johnson/rollup-plugin-optimize-lodash-imports?flag=transform&label=coverage)](https://app.codecov.io/gh/kyle-johnson/rollup-plugin-optimize-lodash-imports/) 7 | 8 | Expects a `parse()` method with output compatible with [`acorn`](https://www.npmjs.com/package/acorn) output. 9 | 10 | Used by: 11 | 12 | - [@optimize-lodash/rollup-plugin](https://www.npmjs.com/package/@optimize-lodash/rollup-plugin) 13 | -------------------------------------------------------------------------------- /packages/transform/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kyle Johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/esbuild-plugin/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kyle Johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/rollup-plugin/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Kyle Johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/esbuild-plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | import * as acorn from "acorn"; 4 | import { Plugin } from "esbuild"; 5 | import { 6 | ParseFunction, 7 | transform, 8 | UNCHANGED, 9 | } from "@optimize-lodash/transform"; 10 | 11 | const wrappedParse: ParseFunction = (code) => 12 | acorn.parse(code, { ecmaVersion: "latest", sourceType: "module" }); 13 | 14 | export type PluginOptions = { 15 | useLodashEs?: true; 16 | }; 17 | 18 | // TODO: filter https://golang.org/pkg/regexp/ 19 | export function lodashOptimizeImports({ 20 | useLodashEs, 21 | }: PluginOptions = {}): Plugin { 22 | const cache = new Map< 23 | string, 24 | { input: string; output: string | UNCHANGED } 25 | >(); 26 | 27 | return { 28 | name: "lodash-optimize-imports", 29 | setup(build) { 30 | build.onLoad({ filter: /.(js|ts|jsx|tsx)$/ }, async ({ path }) => { 31 | const input = await fs.promises.readFile(path, "utf8"); 32 | const cached = cache.get(path); // TODO: unit test the cache 33 | 34 | if (cached && input === cached.input && cached.output === UNCHANGED) { 35 | return; 36 | } 37 | 38 | const result = transform({ 39 | code: input, 40 | id: path, 41 | parse: wrappedParse, 42 | useLodashEs, 43 | }); 44 | if (result === UNCHANGED) { 45 | cache.set(path, { input, output: UNCHANGED }); 46 | return; 47 | } 48 | 49 | const output = result.code; 50 | cache.set(path, { input, output }); 51 | return { contents: output }; 52 | }); 53 | }, 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /packages/transform/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@optimize-lodash/transform", 3 | "version": "2.1.0", 4 | "description": "Rewrites lodash imports in a given source file to be specific.", 5 | "keywords": [ 6 | "lodash", 7 | "optimize" 8 | ], 9 | "homepage": "https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports/tree/main/packages/transform", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports.git" 13 | }, 14 | "main": "dist/index.js", 15 | "types": "dist/index.d.ts", 16 | "files": [ 17 | "dist" 18 | ], 19 | "author": "Kyle Johnson", 20 | "license": "MIT", 21 | "engines": { 22 | "node": ">= 12" 23 | }, 24 | "publishConfig": { 25 | "access": "public" 26 | }, 27 | "scripts": { 28 | "type-check": "tsc --noEmit", 29 | "lint": "eslint .", 30 | "test": "jest", 31 | "test:ci": "jest --coverage --ci", 32 | "build": "rm -rf dist && tsc -p tsconfig.dist.json", 33 | "format": "prettier --write .", 34 | "format:check": "prettier --check .", 35 | "depcheck": "depcheck" 36 | }, 37 | "jest": { 38 | "coverageDirectory": "coverage", 39 | "testEnvironment": "node", 40 | "preset": "ts-jest", 41 | "globals": { 42 | "ts-jest": {} 43 | } 44 | }, 45 | "devDependencies": { 46 | "@tsconfig/node12": "1.0.9", 47 | "@types/estree": "0.0.50", 48 | "@types/jest": "27.0.2", 49 | "@types/lodash": "4.14.177", 50 | "@types/node": "12.20.37", 51 | "@typescript-eslint/eslint-plugin": "5.4.0", 52 | "@typescript-eslint/parser": "5.4.0", 53 | "acorn": "8.5.0", 54 | "depcheck": "1.4.2", 55 | "eslint": "8.2.0", 56 | "eslint-config-prettier": "8.3.0", 57 | "eslint-plugin-jest": "25.2.4", 58 | "eslint-plugin-unicorn": "38.0.1", 59 | "jest": "27.3.1", 60 | "prettier": "2.4.1", 61 | "ts-jest": "27.0.7", 62 | "typescript": "4.3.5" 63 | }, 64 | "dependencies": { 65 | "estree-walker": "2.x", 66 | "magic-string": "0.25.x" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/esbuild-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@optimize-lodash/esbuild-plugin", 3 | "version": "1.0.0", 4 | "description": "Rewrite lodash imports with esbuild for improved tree-shaking.", 5 | "keywords": [ 6 | "lodash", 7 | "esbuild", 8 | "esbuild-plugin", 9 | "optimize", 10 | "minify" 11 | ], 12 | "homepage": "https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports/tree/main/packages/esbuild-plugin", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports.git" 16 | }, 17 | "main": "dist/index.js", 18 | "types": "dist/index.d.ts", 19 | "files": [ 20 | "dist" 21 | ], 22 | "author": "Kyle Johnson", 23 | "license": "MIT", 24 | "engines": { 25 | "node": ">= 12" 26 | }, 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "scripts": { 31 | "type-check": "tsc --noEmit", 32 | "lint": "eslint .", 33 | "test": "jest", 34 | "test:ci": "jest --coverage --ci", 35 | "build": "rm -rf dist && tsc -p tsconfig.dist.json", 36 | "format": "prettier --write .", 37 | "format:check": "prettier --check .", 38 | "depcheck": "depcheck" 39 | }, 40 | "jest": { 41 | "coverageDirectory": "coverage", 42 | "testEnvironment": "node", 43 | "preset": "ts-jest", 44 | "globals": { 45 | "ts-jest": {} 46 | }, 47 | "testTimeout": 10000 48 | }, 49 | "peerDependencies": { 50 | "esbuild": ">= 0.8.0" 51 | }, 52 | "devDependencies": { 53 | "@tsconfig/node12": "1.0.9", 54 | "@types/estree": "0.0.46", 55 | "@types/jest": "27.0.2", 56 | "@types/lodash": "4.14.177", 57 | "@types/node": "10.17.51", 58 | "@typescript-eslint/eslint-plugin": "5.4.0", 59 | "@typescript-eslint/parser": "5.4.0", 60 | "depcheck": "1.4.2", 61 | "esbuild": "0.13.13", 62 | "eslint": "8.2.0", 63 | "eslint-config-prettier": "8.3.0", 64 | "eslint-plugin-jest": "25.2.4", 65 | "eslint-plugin-unicorn": "38.0.1", 66 | "jest": "27.3.1", 67 | "lodash": "4.17.21", 68 | "prettier": "2.4.1", 69 | "ts-jest": "27.0.7", 70 | "typescript": "4.1.6" 71 | }, 72 | "dependencies": { 73 | "@optimize-lodash/transform": "workspace:2.x", 74 | "acorn": "8.x" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /packages/rollup-plugin/tests/__snapshots__/rollup.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`rollup named lodash and lodash/fp imports with plugin & cjs output 1`] = ` 4 | "'use strict'; 5 | 6 | Object.defineProperty(exports, '__esModule', { value: true }); 7 | 8 | var isNil = require('lodash/isNil'); 9 | var negate = require('lodash/negate'); 10 | var every = require('lodash/fp/every'); 11 | 12 | function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } 13 | 14 | var isNil__default = /*#__PURE__*/_interopDefaultLegacy(isNil); 15 | var negate__default = /*#__PURE__*/_interopDefaultLegacy(negate); 16 | var every__default = /*#__PURE__*/_interopDefaultLegacy(every); 17 | 18 | const everyNonNil = every__default[\\"default\\"](negate__default[\\"default\\"](isNil__default[\\"default\\"])); 19 | 20 | function isNonNilArray(input) { 21 | return Array.isArray(input) && everyNonNil(input); 22 | } 23 | 24 | exports.isNonNilArray = isNonNilArray; 25 | " 26 | `; 27 | 28 | exports[`rollup named lodash and lodash/fp imports with plugin & es output 1`] = ` 29 | "import isNil from 'lodash/isNil'; 30 | import negate from 'lodash/negate'; 31 | import every from 'lodash/fp/every'; 32 | 33 | const everyNonNil = every(negate(isNil)); 34 | 35 | function isNonNilArray(input) { 36 | return Array.isArray(input) && everyNonNil(input); 37 | } 38 | 39 | export { isNonNilArray }; 40 | " 41 | `; 42 | 43 | exports[`rollup named lodash and lodash/fp imports with plugin, ES output, & useLodashEs 1`] = ` 44 | "import { negate, isNil } from 'lodash-es'; 45 | import { every } from 'lodash-es/fp'; 46 | 47 | const everyNonNil = every(negate(isNil)); 48 | 49 | function isNonNilArray(input) { 50 | return Array.isArray(input) && everyNonNil(input); 51 | } 52 | 53 | export { isNonNilArray }; 54 | " 55 | `; 56 | 57 | exports[`rollup named lodash and lodash/fp imports without plugin 1`] = ` 58 | "'use strict'; 59 | 60 | Object.defineProperty(exports, '__esModule', { value: true }); 61 | 62 | var lodash = require('lodash'); 63 | var fp = require('lodash/fp'); 64 | 65 | const everyNonNil = fp.every(lodash.negate(lodash.isNil)); 66 | 67 | function isNonNilArray(input) { 68 | return Array.isArray(input) && everyNonNil(input); 69 | } 70 | 71 | exports.isNonNilArray = isNonNilArray; 72 | " 73 | `; 74 | -------------------------------------------------------------------------------- /packages/rollup-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@optimize-lodash/rollup-plugin", 3 | "version": "2.1.0", 4 | "description": "Rewrite lodash imports with Rollup for improved tree-shaking.", 5 | "keywords": [ 6 | "lodash", 7 | "rollup", 8 | "rollup-plugin", 9 | "optimize", 10 | "minify" 11 | ], 12 | "homepage": "https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports/tree/main/packages/rollup-plugin", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports.git" 16 | }, 17 | "main": "dist/index.js", 18 | "types": "dist/index.d.ts", 19 | "files": [ 20 | "dist" 21 | ], 22 | "author": "Kyle Johnson", 23 | "license": "MIT", 24 | "engines": { 25 | "node": ">= 12" 26 | }, 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "scripts": { 31 | "type-check": "tsc --noEmit", 32 | "lint": "eslint .", 33 | "test": "jest", 34 | "test:ci": "jest --coverage --ci", 35 | "build": "rm -rf dist && tsc -p tsconfig.dist.json", 36 | "format": "prettier --write .", 37 | "format:check": "prettier --check .", 38 | "depcheck": "depcheck" 39 | }, 40 | "jest": { 41 | "coverageDirectory": "coverage", 42 | "testEnvironment": "node", 43 | "preset": "ts-jest", 44 | "globals": { 45 | "ts-jest": {} 46 | }, 47 | "testTimeout": 10000 48 | }, 49 | "peerDependencies": { 50 | "rollup": "2.x" 51 | }, 52 | "devDependencies": { 53 | "@rollup/plugin-commonjs": "21.0.1", 54 | "@rollup/plugin-node-resolve": "13.0.6", 55 | "@tsconfig/node12": "1.0.9", 56 | "@types/estree": "0.0.50", 57 | "@types/jest": "27.0.2", 58 | "@types/lodash": "4.14.177", 59 | "@types/node": "12.20.37", 60 | "@typescript-eslint/eslint-plugin": "5.4.0", 61 | "@typescript-eslint/parser": "5.4.0", 62 | "depcheck": "1.4.2", 63 | "eslint": "8.2.0", 64 | "eslint-config-prettier": "8.3.0", 65 | "eslint-plugin-jest": "25.2.4", 66 | "eslint-plugin-unicorn": "38.0.1", 67 | "jest": "27.3.1", 68 | "lodash": "4.17.21", 69 | "prettier": "2.4.1", 70 | "rollup": "2.59.0", 71 | "rollup-plugin-terser": "7.0.2", 72 | "ts-jest": "27.0.7", 73 | "typescript": "4.3.5" 74 | }, 75 | "dependencies": { 76 | "@optimize-lodash/transform": "workspace:2.x", 77 | "@rollup/pluginutils": "4.x" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/rollup-plugin/tests/bundle-size.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Integration tests, verifying that even with a dead-code minifier, the plugin still 3 | * results in significantly smaller outputs when lodash imports are inline into the 4 | * output bundle, as would be required for browser bundles. 5 | * 6 | * These tests can take some time to run, due to additional processing overhead: 7 | * multiple third-party rollup plugins are required to create the bundle. 8 | */ 9 | import { rollup } from "rollup"; 10 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 11 | import { terser } from "rollup-plugin-terser"; 12 | import commonjs from "@rollup/plugin-commonjs"; 13 | 14 | import { optimizeLodashImports } from "../src"; 15 | 16 | const STANDARD_AND_FP = `${__dirname}/fixtures/standard-and-fp.js`; 17 | 18 | const wrapperRollup = async ( 19 | input: string, 20 | enableLodashOptimization: boolean, 21 | enableTerser: boolean 22 | ) => { 23 | // nodeResolve + commonjs = baked in lodash 24 | const plugins = [nodeResolve(), commonjs()]; 25 | if (enableLodashOptimization) { 26 | plugins.push(optimizeLodashImports({ exclude: /node_modules/ })); 27 | } 28 | if (enableTerser) { 29 | plugins.push(terser()); 30 | } 31 | const bundle = await rollup({ 32 | input, 33 | plugins, 34 | }); 35 | const { output } = await bundle.generate({ format: "cjs", validate: true }); 36 | return output[0].code; 37 | }; 38 | 39 | describe("output size is reduced for bundled lodash", () => { 40 | test.each<[boolean]>([[false], [true]])( 41 | "enableTerser: %p", 42 | async (enableTerser) => { 43 | expect.assertions(2); 44 | const [unoptimized, optimized] = await Promise.all( 45 | [false, true].map((enableLodashOptimization) => 46 | wrapperRollup(STANDARD_AND_FP, enableLodashOptimization, enableTerser) 47 | ) 48 | ); 49 | 50 | const improvementPercentage = 51 | (unoptimized.length - optimized.length) / unoptimized.length; 52 | console.log( 53 | `Terser: ${enableTerser ? "yes" : "no"}\nOptimized: ${ 54 | optimized.length 55 | }\nUnoptimized: ${unoptimized.length}\nSize reduction: ${Math.round( 56 | improvementPercentage * 100 57 | )}%` 58 | ); 59 | 60 | // we expect over a 50% improvement 61 | expect(unoptimized.length).toBeGreaterThan(optimized.length); 62 | expect(improvementPercentage).toBeGreaterThan(0.5); 63 | } 64 | ); 65 | }); 66 | -------------------------------------------------------------------------------- /packages/esbuild-plugin/tests/esbuild.test.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | import * as esbuild from "esbuild"; 4 | 5 | import { lodashOptimizeImports } from "../src"; 6 | 7 | describe("esbuild sanity check", () => { 8 | // esbuild is under ongoing development, so this test may give an indicator of 9 | // what might have changed when the change is with esbuild :-) 10 | test.each<[string]>([["no-transform.js"]])("%s", (filename) => { 11 | const result = esbuild.buildSync({ 12 | entryPoints: [path.resolve(__dirname, "fixtures", filename)], 13 | sourcemap: false, 14 | write: false, 15 | format: "cjs", 16 | target: "es6", 17 | }); 18 | 19 | expect(result.outputFiles).toHaveLength(1); 20 | expect( 21 | Buffer.from(result.outputFiles[0].contents).toString("utf-8") 22 | ).toMatchSnapshot(); 23 | }); 24 | }); 25 | 26 | describe("esbuild with lodashOptimizeImports()", () => { 27 | test.each<[string]>([["standard-and-fp.js"]])("CJS: %s", async (filename) => { 28 | const result = await esbuild.build({ 29 | entryPoints: [path.resolve(__dirname, "fixtures", filename)], 30 | sourcemap: false, 31 | write: false, 32 | format: "cjs", 33 | target: "es2020", 34 | plugins: [lodashOptimizeImports()], 35 | }); 36 | 37 | expect(result.outputFiles).toHaveLength(1); 38 | const code = Buffer.from(result.outputFiles[0].contents).toString("utf-8"); 39 | 40 | // ensure all imports became more specific 41 | expect(code).not.toMatch(/["']lodash["']/g); 42 | expect(code).not.toMatch(/["']lodash\/fp["']/g); 43 | 44 | expect(code).toMatchSnapshot(); 45 | }); 46 | 47 | test.each<[string]>([["standard-and-fp.js"]])("ESM: %s", async (filename) => { 48 | const result = await esbuild.build({ 49 | entryPoints: [path.resolve(__dirname, "fixtures", filename)], 50 | sourcemap: false, 51 | write: false, 52 | format: "esm", 53 | target: "es2020", 54 | plugins: [lodashOptimizeImports({ useLodashEs: true })], 55 | }); 56 | 57 | expect(result.outputFiles).toHaveLength(1); 58 | const code = Buffer.from(result.outputFiles[0].contents).toString("utf-8"); 59 | 60 | // ensure all imports became more specific 61 | expect(code).not.toMatch(/["']lodash["']/g); 62 | expect(code).not.toMatch(/["']lodash\/fp["']/g); 63 | 64 | // ensure we have some lodash-es imports 65 | expect(code).toMatch(/["']lodash-es["']/g); 66 | 67 | expect(code).toMatchSnapshot(); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Optimize `lodash` imports 2 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/kyle-johnson/rollup-plugin-optimize-lodash-imports/CI)](https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports/actions) 3 | ![LGTM Grade](https://img.shields.io/lgtm/grade/javascript/github/kyle-johnson/rollup-plugin-optimize-lodash-imports) 4 | [![Codecov](https://img.shields.io/codecov/c/github/kyle-johnson/rollup-plugin-optimize-lodash-imports?label=coverage)](https://app.codecov.io/gh/kyle-johnson/rollup-plugin-optimize-lodash-imports/) 5 | 6 | There are [multiple](https://github.com/webpack/webpack/issues/6925) [issues](https://github.com/lodash/lodash/issues/3839) [surrounding](https://github.com/rollup/rollup/issues/1403) [tree-shaking](https://github.com/rollup/rollup/issues/691) of lodash. Minifiers, even with dead-code elimination, cannot currently solve this problem. 7 | 8 | Plugins can reduce final bundle sizes with minimal or no manual code changes. See the example showing [a 70% reduced bundle size](https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports/blob/main/packages/rollup-plugin/tests/bundle-size.test.ts) for [an example input](https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports/blob/main/packages/rollup-plugin/tests/fixtures/standard-and-fp.js). 9 | 10 | # Packages: 11 | 12 | ## [@optimize-lodash/rollup-plugin](https://www.npmjs.com/package/@optimize-lodash/rollup-plugin) 13 | [![npm](https://img.shields.io/npm/v/@optimize-lodash/rollup-plugin)](https://www.npmjs.com/package/@optimize-lodash/rollup-plugin) 14 | ![node-current](https://img.shields.io/node/v/@optimize-lodash/rollup-plugin) 15 | ![npm peer dependency version](https://img.shields.io/npm/dependency-version/@optimize-lodash/rollup-plugin/peer/rollup) 16 | 17 | A fast, lightweight plugin for Rollup bundling. 18 | 19 | ## [@optimize-lodash/esbuild-plugin](https://www.npmjs.com/package/@optimize-lodash/esbuild-plugin) 20 | [![npm](https://img.shields.io/npm/v/@optimize-lodash/esbuild-plugin)](https://www.npmjs.com/package/@optimize-lodash/esbuild-plugin) 21 | ![node-current](https://img.shields.io/node/v/@optimize-lodash/esbuild-plugin) 22 | ![npm peer dependency version](https://img.shields.io/npm/dependency-version/@optimize-lodash/esbuild-plugin/peer/esbuild) 23 | 24 | A *expertimental* plugin for esbuild bundling. *(Experimental = esbuild is rapidly changing and unlike the Rollup plugin, this has not been used in a production code base.)* 25 | 26 | ## [@optimize-lodash/transform](https://www.npmjs.com/package/@optimize-lodash/transform) 27 | [![npm](https://img.shields.io/npm/v/@optimize-lodash/transform)](https://www.npmjs.com/package/@optimize-lodash/transform) 28 | 29 | Code transforms for lodash imports. Used by bundler plugins for a consistent, well-tested, shared set of transforms. 30 | -------------------------------------------------------------------------------- /packages/rollup-plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from "rollup"; 2 | import { createFilter, FilterPattern } from "@rollup/pluginutils"; 3 | import { transform as lodashTransform } from "@optimize-lodash/transform"; 4 | 5 | export type OptimizeLodashOptions = { 6 | /** 7 | * A minimatch pattern, or array of patterns, of files that should be 8 | * processed by this plugin (if omitted, all files are included by default) 9 | */ 10 | include?: FilterPattern; 11 | /** 12 | * Files that should be excluded, if `include` is otherwise too permissive. 13 | */ 14 | exclude?: FilterPattern; 15 | 16 | /** 17 | * Changes *all* lodash imports (but not lodash/fp imports!) to 'lodash-es' imports. 18 | * Don't use this for CommonJS outputs, the plugin will error should you do so. 19 | */ 20 | useLodashEs?: true; 21 | }; 22 | 23 | const UNCHANGED = null; 24 | 25 | /** 26 | * Converts lodash imports to be specific, enabling better tree-shaking: 27 | * 28 | * `import { isNil } from "lodash";` -> `import { isNil } from "lodash/isNil";` 29 | * 30 | * Note that only specific named imports are supported, unlike babel-plugin-lodash. For example, 31 | * this plugin will print a warning for this import and make no changes to the import: 32 | * 33 | * `import _ from "lodash";` 34 | * 35 | * Optionally, set `useLodashEs` to true and `lodash` imports will be converted to `lodash-es` 36 | * imports. Note that it's up to user to include the `lodash-es` module and ensure the output 37 | * is set to some form of `es` (other output formats will error). An example: 38 | * 39 | * `import { isNil } from "lodash";` -> `import { isNil } from "lodash-es";` 40 | * 41 | * @param include files/globs to include with this plugin (optional) 42 | * @param exclude files/globs to exclude from this plugin (optional) 43 | * @param useLodashEs set `true` to convert imports to use "lodash-es" (optional; default false) 44 | */ 45 | export function optimizeLodashImports({ 46 | include, 47 | exclude, 48 | useLodashEs, 49 | }: OptimizeLodashOptions = {}): Plugin & Required> { 50 | const filter = createFilter(include, exclude); 51 | 52 | return { 53 | name: "optimize-lodash-imports", 54 | outputOptions(options) { 55 | if (useLodashEs && options.format !== "es") { 56 | this.error( 57 | `'useLodashEs' is true but the output format is not 'es', it's ${ 58 | options.format ?? "undefined" 59 | }` 60 | ); 61 | } 62 | return UNCHANGED; 63 | }, 64 | transform(code, id) { 65 | const warn = this.warn.bind(this); 66 | const parse = this.parse.bind(this); 67 | 68 | // honor include/exclude 69 | if (!filter(id)) { 70 | return UNCHANGED; 71 | } 72 | 73 | return lodashTransform({ code, id, parse, warn, useLodashEs }); 74 | }, 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /packages/esbuild-plugin/tests/__snapshots__/esbuild.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`esbuild sanity check no-transform.js 1`] = ` 4 | "var __defProp = Object.defineProperty; 5 | var __markAsModule = (target) => __defProp(target, \\"__esModule\\", { value: true }); 6 | var __export = (target, all) => { 7 | __markAsModule(target); 8 | for (var name in all) 9 | __defProp(target, name, { get: all[name], enumerable: true }); 10 | }; 11 | __export(exports, { 12 | hello: () => hello 13 | }); 14 | function hello(name) { 15 | return \`Hello \${name != null ? name : \\"World\\"}!\`; 16 | } 17 | " 18 | `; 19 | 20 | exports[`esbuild with lodashOptimizeImports() CJS: standard-and-fp.js 1`] = ` 21 | "var __create = Object.create; 22 | var __defProp = Object.defineProperty; 23 | var __getOwnPropDesc = Object.getOwnPropertyDescriptor; 24 | var __getOwnPropNames = Object.getOwnPropertyNames; 25 | var __getProtoOf = Object.getPrototypeOf; 26 | var __hasOwnProp = Object.prototype.hasOwnProperty; 27 | var __markAsModule = (target) => __defProp(target, \\"__esModule\\", { value: true }); 28 | var __export = (target, all) => { 29 | __markAsModule(target); 30 | for (var name in all) 31 | __defProp(target, name, { get: all[name], enumerable: true }); 32 | }; 33 | var __reExport = (target, module2, desc) => { 34 | if (module2 && typeof module2 === \\"object\\" || typeof module2 === \\"function\\") { 35 | for (let key of __getOwnPropNames(module2)) 36 | if (!__hasOwnProp.call(target, key) && key !== \\"default\\") 37 | __defProp(target, key, { get: () => module2[key], enumerable: !(desc = __getOwnPropDesc(module2, key)) || desc.enumerable }); 38 | } 39 | return target; 40 | }; 41 | var __toModule = (module2) => { 42 | return __reExport(__markAsModule(__defProp(module2 != null ? __create(__getProtoOf(module2)) : {}, \\"default\\", module2 && module2.__esModule && \\"default\\" in module2 ? { get: () => module2.default, enumerable: true } : { value: module2, enumerable: true })), module2); 43 | }; 44 | __export(exports, { 45 | isNonNilArray: () => isNonNilArray 46 | }); 47 | var import_isNil = __toModule(require(\\"lodash/isNil\\")); 48 | var import_negate = __toModule(require(\\"lodash/negate\\")); 49 | var import_every = __toModule(require(\\"lodash/fp/every\\")); 50 | const everyNonNil = (0, import_every.default)((0, import_negate.default)(import_isNil.default)); 51 | function isNonNilArray(input) { 52 | return Array.isArray(input) && everyNonNil(input); 53 | } 54 | " 55 | `; 56 | 57 | exports[`esbuild with lodashOptimizeImports() ESM: standard-and-fp.js 1`] = ` 58 | "import { isNil } from \\"lodash-es\\"; 59 | import { negate } from \\"lodash-es\\"; 60 | import { every } from \\"lodash-es/fp\\"; 61 | const everyNonNil = every(negate(isNil)); 62 | function isNonNilArray(input) { 63 | return Array.isArray(input) && everyNonNil(input); 64 | } 65 | export { 66 | isNonNilArray 67 | }; 68 | " 69 | `; 70 | -------------------------------------------------------------------------------- /packages/rollup-plugin/tests/rollup.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Basic end-to-end integration tests with Rollup. 3 | * 4 | * No mocks/stubs, and output is validated. 5 | */ 6 | import { OutputOptions, rollup } from "rollup"; 7 | 8 | import { optimizeLodashImports, OptimizeLodashOptions } from "../src"; 9 | 10 | // quiet auto-external warnings with a simple auto-external test 11 | const external = (id: string): boolean => /^[^./]/.test(id); 12 | 13 | /** 14 | * Simplify tests by running rollup and generating output. Errors within rollup will 15 | * throw. The lodash plugin can be disabled by passing `false` in place of options. 16 | * 17 | * @param input input filename 18 | * @param pluginOptions set `false` to disable the plugin 19 | * @param rollupOutputFormat cjs, es, etc 20 | */ 21 | const wrapperRollupGenerate = async ( 22 | input: string, 23 | pluginOptions: OptimizeLodashOptions | false, 24 | rollupOutputFormat: OutputOptions["format"] = "cjs" 25 | ): Promise => { 26 | const bundle = await rollup({ 27 | input, 28 | external, 29 | plugins: 30 | pluginOptions !== false ? [optimizeLodashImports(pluginOptions)] : [], 31 | }); 32 | const { output } = await bundle.generate({ 33 | format: rollupOutputFormat, 34 | validate: true, 35 | }); 36 | return output[0].code; 37 | }; 38 | 39 | const STANDARD_AND_FP = `${__dirname}/fixtures/standard-and-fp.js`; 40 | 41 | describe("rollup", () => { 42 | test("setting `useLodashEs` to true with output format `cjs` throws", async () => { 43 | await expect( 44 | wrapperRollupGenerate(STANDARD_AND_FP, { useLodashEs: true }, "cjs") 45 | ).rejects.toThrowErrorMatchingInlineSnapshot( 46 | `"'useLodashEs' is true but the output format is not 'es', it's cjs"` 47 | ); 48 | }); 49 | 50 | describe("named lodash and lodash/fp imports", () => { 51 | test("without plugin", async () => { 52 | expect.assertions(3); 53 | const code = await wrapperRollupGenerate(STANDARD_AND_FP, false, "cjs"); 54 | 55 | // ensure all imports remained untouched 56 | expect(code).toMatch(/["']lodash["']/g); 57 | expect(code).toMatch(/["']lodash\/fp["']/g); 58 | 59 | // full snapshot 60 | expect(code).toMatchSnapshot(); 61 | }); 62 | 63 | test.each<[OutputOptions["format"]]>([["cjs"], ["es"]])( 64 | "with plugin & %s output", 65 | async (outputFormat) => { 66 | expect.assertions(3); 67 | const code = await wrapperRollupGenerate( 68 | STANDARD_AND_FP, 69 | {}, 70 | outputFormat 71 | ); 72 | 73 | // ensure all imports became more specific 74 | expect(code).not.toMatch(/["']lodash["']/g); 75 | expect(code).not.toMatch(/["']lodash\/fp["']/g); 76 | 77 | // full snapshot 78 | expect(code).toMatchSnapshot(); 79 | } 80 | ); 81 | 82 | test("with plugin, ES output, & useLodashEs", async () => { 83 | expect.assertions(4); 84 | const code = await wrapperRollupGenerate( 85 | STANDARD_AND_FP, 86 | { useLodashEs: true }, 87 | "es" 88 | ); 89 | 90 | // ensure all imports became more specific 91 | expect(code).not.toMatch(/["']lodash["']/g); 92 | expect(code).not.toMatch(/["']lodash\/fp["']/g); 93 | expect(code).toMatch(/["']lodash-es["']/g); 94 | 95 | // full snapshot 96 | expect(code).toMatchSnapshot(); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /packages/transform/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from "acorn"; 2 | import MagicString, { SourceMap } from "magic-string"; 3 | import { walk } from "estree-walker"; 4 | 5 | import { 6 | isImportDeclaration, 7 | isImportSpecifierArray, 8 | isProgram, 9 | } from "./guards"; 10 | import { lodashSpecifiersToEs } from "./lodash-specifiers-to-es"; 11 | import { lodashSpecifiersToCjs } from "./lodash-specifiers-to-cjs"; 12 | 13 | // acorn adds these 14 | declare module "estree" { 15 | interface BaseNodeWithoutComments { 16 | // added by acorn 17 | start: number; 18 | end: number; 19 | } 20 | } 21 | 22 | export type UNCHANGED = null; 23 | export const UNCHANGED = null; 24 | 25 | export interface CodeWithSourcemap { 26 | code: string; 27 | map: SourceMap; 28 | } 29 | export type ParseFunction = (code: string) => Node; 30 | export type WarnFunction = (message: string) => void; 31 | 32 | export function transform({ 33 | code, 34 | id, 35 | parse, 36 | warn, 37 | useLodashEs, 38 | }: { 39 | code: string; 40 | id: string; 41 | parse: ParseFunction; 42 | warn?: WarnFunction; 43 | useLodashEs?: true; 44 | }): CodeWithSourcemap | UNCHANGED { 45 | // before parsing, check if we can skip the whole file 46 | if (!code.includes("lodash")) { 47 | return UNCHANGED; 48 | } 49 | 50 | let ast; 51 | try { 52 | ast = parse(code); 53 | } catch (error) { 54 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 55 | error.message += ` in ${id}`; 56 | throw error; 57 | } 58 | 59 | // source map generation 60 | let magicString: MagicString | undefined; 61 | 62 | walk(ast, { 63 | enter(node) { 64 | // top-level node; we need to walk its children to find imports 65 | if (isProgram(node)) { 66 | return; 67 | } 68 | 69 | // skip any nodes that aren't imports (this skips most everything) 70 | if (!isImportDeclaration(node)) { 71 | this.skip(); 72 | return; 73 | } 74 | 75 | // narrow-in on lodash imports we care about 76 | if (node.source.value !== "lodash" && node.source.value !== "lodash/fp") { 77 | this.skip(); 78 | return; 79 | } 80 | 81 | // transform specific "lodash" and "lodash/fp" imports such as: 82 | // import { isNil } from "lodash"; 83 | if (isImportSpecifierArray(node.specifiers)) { 84 | magicString = magicString ?? new MagicString(code); 85 | 86 | // modify 87 | const imports = useLodashEs 88 | ? lodashSpecifiersToEs(node.source.value, node.specifiers) 89 | : lodashSpecifiersToCjs(node.source.value, node.specifiers); 90 | 91 | // write 92 | magicString.overwrite(node.start, node.end, imports.join("\n")); 93 | 94 | // no need to dig deeper 95 | this.skip(); 96 | } else if (warn !== undefined) { 97 | // help end-users benefit from this plugin (this behavior differs from 98 | // babel-plugin-lodash which does optimize non-specific imports) 99 | warn( 100 | `Detected a default lodash or lodash/fp import within ${id} on line ${ 101 | node.loc?.start?.line ?? "unknown" 102 | }.\nThis import cannot be optimized by optimize-lodash-imports.` 103 | ); 104 | } 105 | }, 106 | }); 107 | 108 | if (!magicString) { 109 | return UNCHANGED; 110 | } 111 | 112 | return { 113 | code: magicString.toString(), 114 | map: magicString.generateMap({ 115 | file: id, 116 | includeContent: true, 117 | hires: true, 118 | }), 119 | }; 120 | } 121 | -------------------------------------------------------------------------------- /packages/rollup-plugin/tests/test.ts: -------------------------------------------------------------------------------- 1 | import type { TransformPluginContext } from "rollup"; 2 | import { ParseFunction, transform } from "@optimize-lodash/transform"; 3 | 4 | import { optimizeLodashImports, OptimizeLodashOptions } from "../src"; 5 | 6 | jest.mock("@optimize-lodash/transform"); 7 | const sourceTransformerMock = transform as jest.MockedFunction< 8 | typeof transform 9 | >; 10 | 11 | const UNCHANGED = null; 12 | type UNCHANGED = null; 13 | 14 | describe("optimizeLodashImports", () => { 15 | const warnMock: jest.MockedFunction = 16 | jest.fn(); 17 | const parseMock: jest.MockedFunction = jest.fn(); 18 | const wrapperPlugin = ( 19 | input: string, 20 | id: string, 21 | options: OptimizeLodashOptions 22 | ) => 23 | optimizeLodashImports(options).transform.call( 24 | { 25 | parse: parseMock as ParseFunction, 26 | warn: warnMock as TransformPluginContext["warn"], 27 | } as TransformPluginContext, 28 | input, 29 | id 30 | ); 31 | 32 | beforeEach(() => { 33 | sourceTransformerMock.mockReset(); 34 | warnMock.mockReset(); 35 | parseMock.mockReset(); 36 | }); 37 | 38 | test("source-transform is called with bound `warn` and `parse` methods", () => { 39 | const CODE = `import hello from "world";`; 40 | const SOURCE_ID = "my-source-id"; 41 | // verify binding of warn and parse 42 | sourceTransformerMock.mockImplementationOnce(({ warn, parse }) => { 43 | if (warn) { 44 | warn("warning"); 45 | } 46 | parse("code to parse"); 47 | return UNCHANGED; 48 | }); 49 | 50 | const result = optimizeLodashImports().transform.call( 51 | { 52 | parse: parseMock as ParseFunction, 53 | warn: warnMock as TransformPluginContext["warn"], 54 | } as TransformPluginContext /* we're only providing what this stage of the plugin requires */, 55 | CODE, 56 | SOURCE_ID 57 | ); 58 | expect(result).toBe(UNCHANGED); 59 | 60 | expect(sourceTransformerMock).toHaveBeenCalledTimes(1); 61 | expect(sourceTransformerMock).toHaveBeenLastCalledWith( 62 | expect.objectContaining({ 63 | code: CODE, 64 | id: SOURCE_ID, 65 | useLodashEs: undefined, 66 | }) 67 | ); 68 | expect(warnMock).toHaveBeenCalledTimes(1); 69 | expect(parseMock).toHaveBeenCalledTimes(1); 70 | }); 71 | 72 | test("by default, no sources are skipped", () => { 73 | sourceTransformerMock.mockReturnValueOnce(UNCHANGED); 74 | void wrapperPlugin("hello", "my-file.js", {}); 75 | expect(sourceTransformerMock).toHaveBeenCalled(); 76 | }); 77 | 78 | test("excluded sources are skipped", () => { 79 | sourceTransformerMock.mockReturnValueOnce(UNCHANGED); 80 | const result = wrapperPlugin("hello", "skip-me.js", { exclude: /skip/ }); 81 | expect(result).toBe(UNCHANGED); 82 | expect(sourceTransformerMock).not.toHaveBeenCalled(); 83 | }); 84 | 85 | test("when include doesn't match a source, the source is excluded", () => { 86 | sourceTransformerMock.mockReturnValueOnce(UNCHANGED); 87 | const result = wrapperPlugin("hello", "skip-me.js", { include: /include/ }); 88 | expect(result).toBe(UNCHANGED); 89 | expect(sourceTransformerMock).not.toHaveBeenCalled(); 90 | }); 91 | 92 | test.each<[true | undefined]>([[undefined], [true]])( 93 | "useLodashEs (%p) is passed to source-transform", 94 | (useLodashEs) => { 95 | sourceTransformerMock.mockReturnValueOnce(UNCHANGED); 96 | const result = wrapperPlugin("hello", "parse-me.js", { useLodashEs }); 97 | expect(result).toBe(UNCHANGED); 98 | expect(sourceTransformerMock).toHaveBeenCalledTimes(1); 99 | expect(sourceTransformerMock).toHaveBeenLastCalledWith( 100 | expect.objectContaining({ 101 | useLodashEs, 102 | }) 103 | ); 104 | } 105 | ); 106 | }); 107 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | src/*.js 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # TypeScript v1 declaration files 47 | typings/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Microbundle cache 59 | .rpt2_cache/ 60 | .rts2_cache_cjs/ 61 | .rts2_cache_es/ 62 | .rts2_cache_umd/ 63 | 64 | # Optional REPL history 65 | .node_repl_history 66 | 67 | # Output of 'npm pack' 68 | *.tgz 69 | 70 | # Yarn Integrity file 71 | .yarn-integrity 72 | 73 | # dotenv environment variables file 74 | .env 75 | .env.test 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | 80 | # Next.js build output 81 | .next 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Cloud9 IDE - http://c9.io 109 | .c9revisions 110 | .c9 111 | 112 | # Dropbox settings and caches 113 | .dropbox 114 | .dropbox.attr 115 | .dropbox.cache 116 | 117 | # Emacs 118 | *~ 119 | \#*\# 120 | /.emacs.desktop 121 | /.emacs.desktop.lock 122 | *.elc 123 | auto-save-list 124 | tramp 125 | .\#* 126 | 127 | # Org-mode 128 | .org-id-locations 129 | *_archive 130 | 131 | # flymake-mode 132 | *_flymake.* 133 | 134 | # eshell files 135 | /eshell/history 136 | /eshell/lastdir 137 | 138 | # elpa packages 139 | /elpa/ 140 | 141 | # reftex files 142 | *.rel 143 | 144 | # AUCTeX auto folder 145 | /auto/ 146 | 147 | # cask packages 148 | .cask/ 149 | dist/ 150 | 151 | # Flycheck 152 | flycheck_*.el 153 | 154 | # server auth directory 155 | /server/ 156 | 157 | # projectiles files 158 | .projectile 159 | 160 | # directory configuration 161 | .dir-locals.el 162 | 163 | # network security 164 | /network-security.data 165 | 166 | # Jetbrains 167 | .idea/ 168 | .idea_modules/ 169 | 170 | # Linux 171 | *~ 172 | 173 | # temporary files which can be created if a process still has a handle open of a deleted file 174 | .fuse_hidden* 175 | 176 | # KDE directory preferences 177 | .directory 178 | 179 | # Linux trash folder which might appear on any partition or disk 180 | .Trash-* 181 | 182 | # .nfs files are created when an open file is removed but is still being accessed 183 | .nfs* 184 | 185 | # Sublime Text 186 | *.tmlanguage.cache 187 | *.tmPreferences.cache 188 | *.stTheme.cache 189 | *.sublime-workspace 190 | 191 | # VIM 192 | [._]*.s[a-v][a-z] 193 | !*.svg # comment out if you don't need vector files 194 | [._]*.sw[a-p] 195 | [._]s[a-rt-v][a-z] 196 | [._]ss[a-gi-z] 197 | [._]sw[a-p] 198 | Session.vim 199 | Sessionx.vim 200 | .netrwhist 201 | tags 202 | [._]*.un~ 203 | 204 | # VSCode 205 | .vscode/* 206 | *.code-workspace 207 | .history/ 208 | 209 | # OSX 210 | # General 211 | .DS_Store 212 | .AppleDouble 213 | .LSOverride 214 | 215 | # Thumbnails 216 | ._* 217 | 218 | # Files that might appear in the root of a volume 219 | .DocumentRevisions-V100 220 | .fseventsd 221 | .Spotlight-V100 222 | .TemporaryItems 223 | .Trashes 224 | .VolumeIcon.icns 225 | .com.apple.timemachine.donotpresent 226 | 227 | # Directories potentially created on remote AFP share 228 | .AppleDB 229 | .AppleDesktop 230 | Network Trash Folder 231 | Temporary Items 232 | .apdisk 233 | -------------------------------------------------------------------------------- /packages/esbuild-plugin/README.md: -------------------------------------------------------------------------------- 1 | # Optimize `lodash` imports with esbuild 2 | 3 | [![npm](https://img.shields.io/npm/v/@optimize-lodash/esbuild-plugin)](https://www.npmjs.com/package/@optimize-lodash/esbuild-plugin) 4 | ![node-current](https://img.shields.io/node/v/@optimize-lodash/esbuild-plugin) 5 | ![npm peer dependency version](https://img.shields.io/npm/dependency-version/@optimize-lodash/esbuild-plugin/peer/esbuild) 6 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/kyle-johnson/rollup-plugin-optimize-lodash-imports/CI)](https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports/actions) 7 | ![LGTM Grade](https://img.shields.io/lgtm/grade/javascript/github/kyle-johnson/rollup-plugin-optimize-lodash-imports) 8 | [![license](https://img.shields.io/npm/l/@optimize-lodash/esbuild-plugin)](https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports/blob/main/packages/esbuild-plugin/LICENSE) 9 | [![Codecov](https://img.shields.io/codecov/c/github/kyle-johnson/rollup-plugin-optimize-lodash-imports?flag=esbuild-plugin&label=coverage)](https://app.codecov.io/gh/kyle-johnson/rollup-plugin-optimize-lodash-imports/) 10 | ![GitHub last commit](https://img.shields.io/github/last-commit/kyle-johnson/rollup-plugin-optimize-lodash-imports) 11 | 12 | _**This is a proof of concept! esbuild loader plugins are "greedy" and need additional code to enable chaining. If you want something that's proven in production, consider using [@optimize-lodash/rollup-plugin](https://www.npmjs.com/package/@optimize-lodash/rollup-plugin).**_ 13 | 14 | There are [multiple](https://github.com/webpack/webpack/issues/6925) [issues](https://github.com/lodash/lodash/issues/3839) [surrounding](https://github.com/rollup/rollup/issues/1403) [tree-shaking](https://github.com/rollup/rollup/issues/691) of lodash. Minifiers, even with dead-code elimination, cannot currently solve this problem. With this plugin, bundled code output will _only_ include the specific lodash methods your code requires. 15 | 16 | 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`. 17 | 18 | ### This input 19 | 20 | ```javascript 21 | import { isNil, isString } from "lodash"; 22 | import { padStart as padStartFp } from "lodash/fp"; 23 | ``` 24 | 25 | ### Becomes this output 26 | 27 | ```javascript 28 | import isNil from "lodash/isNil"; 29 | import isString from "lodash/isString"; 30 | import padStartFp from "lodash/fp/padStart"; 31 | ``` 32 | 33 | ## `useLodashEs` for ES Module Output 34 | 35 | While `lodash-es` is not usable from CommonJS modules, some projects use Rollup to create two outputs: one for ES and one for CommonJS. 36 | 37 | In this case, you can offer your users the best of both: 38 | 39 | ### Your source input 40 | 41 | ```javascript 42 | import { isNil } from "lodash"; 43 | ``` 44 | 45 | #### CommonJS output 46 | 47 | ```javascript 48 | import isNil from "lodash/isNil"; 49 | ``` 50 | 51 | #### ES output (with `useLodashEs: true`) 52 | 53 | ```javascript 54 | import { isNil } from "lodash-es"; 55 | ``` 56 | 57 | ## Usage 58 | 59 | _Please see the [esbuild docs for the most up to date info on using plugins](https://esbuild.github.io/plugins/#using-plugins)._ 60 | 61 | ```javascript 62 | const { lodashOptimizeImports } = require("@optimize-lodash/esbuild-plugin"); 63 | 64 | require("esbuild").buildSync({ 65 | entryPoints: ["app.js"], 66 | outfile: "out.js", 67 | plugins: [lodashOptimizeImports()], 68 | }); 69 | ``` 70 | 71 | ## Options 72 | 73 | ### `useLodashEs` 74 | 75 | Type: `boolean`
76 | Default: `false` 77 | 78 | If `true`, the plugin will rewrite _lodash_ imports to use _lodash-es_. 79 | 80 | **\*NOTE:** be sure esbuild's `format: "esm"` option is set!\* 81 | 82 | ## Limitations 83 | 84 | ### Default imports are not optimized 85 | 86 | Unlike [babel-plugin-lodash](https://github.com/lodash/babel-plugin-lodash), there is no support for optimizing the lodash default import, such as in this case: 87 | 88 | ```javascript 89 | // this import can't be optimized 90 | import _ from "lodash"; 91 | 92 | export function testX(x) { 93 | return _.isNil(x); 94 | } 95 | ``` 96 | 97 | The above code will not be optimized, and Rollup will print a warning. 98 | 99 | To avoid this, always import the specific method(s) you need: 100 | 101 | ```javascript 102 | // this import will be optimized 103 | import { isNil } from "lodash"; 104 | 105 | export function testX(x) { 106 | return isNil(x); 107 | } 108 | ``` 109 | 110 | ## Alternatives 111 | 112 | There aren't a lot of esbuild plugins today. 113 | 114 | If you wish to shift the responsibility off to developers, `eslint-plugin-lodash` with the [`import-scope` rule enabled](https://github.com/wix/eslint-plugin-lodash/blob/HEAD/docs/rules/import-scope.md) may help. 115 | 116 | Using Rollup? Check out [`@optimize-lodash/rollup-plugin`](https://www.npmjs.com/package/@optimize-lodash/rollup-plugin). 117 | 118 | Using Babel? Check out [`babel-plugin-lodash`](https://github.com/lodash/babel-plugin-lodash) (it fixes default imports too!). 119 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | # allow manual runs 9 | workflow_dispatch: 10 | env: 11 | PNPM_VERSION: 6.16.1 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 3 16 | strategy: 17 | matrix: 18 | node: [12] 19 | package: ["rollup-plugin", "transform", "esbuild-plugin"] 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: actions/setup-node@v2 23 | with: 24 | node-version: ${{ matrix.node }} 25 | - name: install pnpm 26 | run: npm install -g pnpm@$PNPM_VERSION 27 | - name: Month and Year (for cache busting) 28 | id: month-and-year 29 | shell: bash 30 | run: | 31 | echo "::set-output name=date::$(/bin/date -u \"+%Y%m\")" 32 | - uses: actions/cache@v2 33 | with: 34 | path: ~/.pnpm-store 35 | key: ${{ runner.os }}-${{ matrix.node }}-${{ github.workflow }}-${{ steps.month-and-year.outputs.date }}-${{ hashFiles('./pnpm-lock.yaml') }} 36 | restore-keys: | 37 | ${{ runner.os }}-${{ matrix.node }}-${{ github.workflow }}-${{ steps.month-and-year.outputs.date }}- 38 | - name: install deps 39 | run: pnpm install --frozen-lockfile --filter {./packages/${{ matrix.package }}}... 40 | - name: build 41 | run: pnpm run build --filter {./packages/${{ matrix.package }}}... 42 | test: 43 | runs-on: ubuntu-latest 44 | timeout-minutes: 2 45 | strategy: 46 | max-parallel: 4 47 | matrix: 48 | package: ["rollup-plugin", "transform", "esbuild-plugin"] 49 | command: ["test:ci"] 50 | node: [ 12, 16 ] # if 12 and 16 pass, assume 14 does too! 51 | include: 52 | - package: "rollup-plugin" 53 | command: lint 54 | node: 16 55 | - package: "transform" 56 | command: lint 57 | node: 16 58 | - package: "rollup-plugin" 59 | command: "format:check" 60 | node: 16 61 | - package: "transform" 62 | command: "format:check" 63 | node: 16 64 | - package: "rollup-plugin" 65 | command: "depcheck" 66 | node: 16 67 | - package: "transform" 68 | command: "depcheck" 69 | node: 16 70 | - package: "rollup-plugin" 71 | command: "type-check" 72 | node: 16 73 | - package: "transform" 74 | command: "type-check" 75 | node: 16 76 | steps: 77 | - uses: actions/checkout@v2 78 | - uses: actions/setup-node@v2 79 | with: 80 | node-version: ${{ matrix.node }} 81 | - name: install pnpm 82 | run: npm install -g pnpm@$PNPM_VERSION 83 | - name: Month and Year (for cache busting) 84 | id: month-and-year 85 | shell: bash 86 | run: | 87 | echo "::set-output name=date::$(/bin/date -u \"+%Y%m\")" 88 | - uses: actions/cache@v2 89 | with: 90 | path: ~/.pnpm-store 91 | key: ${{ runner.os }}-10-${{ github.workflow }}-${{ steps.month-and-year.outputs.date }}-${{ hashFiles('./pnpm-lock.yaml') }} 92 | restore-keys: | 93 | ${{ runner.os }}-10-${{ github.workflow }}-${{ steps.month-and-year.outputs.date }}- 94 | - name: install deps 95 | run: pnpm install --frozen-lockfile --filter {./packages/${{ matrix.package }}}... 96 | - name: build deps 97 | run: pnpm run build --filter {./packages/${{ matrix.package }}}^... 98 | - name: ${{ matrix.command }} 99 | run: pnpm run ${{ matrix.command }} --filter {./packages/${{ matrix.package }}} 100 | - name: coverage 101 | uses: codecov/codecov-action@v2 102 | if: matrix.command == 'test:ci' && matrix.node == '16' 103 | with: 104 | flags: ${{ matrix.package }} 105 | files: ./packages/${{ matrix.package }}/coverage/coverage-final.json 106 | release: 107 | if: github.ref == 'refs/heads/main' 108 | needs: ["test", "build"] 109 | name: Release 110 | runs-on: ubuntu-latest 111 | steps: 112 | - uses: actions/checkout@v2 113 | with: 114 | fetch-depth: 0 115 | - uses: actions/setup-node@v2 116 | with: 117 | node-version: 12 118 | - name: install pnpm 119 | run: npm install -g pnpm@$PNPM_VERSION 120 | - name: install deps 121 | run: pnpm install --frozen-lockfile 122 | - name: build all 123 | run: pnpm run -r build 124 | - name: write .npmrc 125 | run: printf "//registry.npmjs.org/:_authToken=%s" "$NPM_TOKEN" > ~/.npmrc 126 | env: 127 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 128 | - name: Changesets 129 | id: Changesets 130 | uses: changesets/action@master 131 | with: 132 | publish: pnpm -r publish --no-git-checks 133 | version: pnpm changesets version 134 | commit: "chore: update versions" 135 | title: "chore: update versions" 136 | env: 137 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 138 | -------------------------------------------------------------------------------- /packages/transform/tests/test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/no-conditional-expect */ 2 | import * as acorn from "acorn"; 3 | 4 | import { CodeWithSourcemap, transform, UNCHANGED, WarnFunction } from "../src"; 5 | 6 | const warnMock: jest.MockedFunction = jest.fn(); 7 | beforeEach(() => { 8 | warnMock.mockReset(); 9 | }); 10 | 11 | // implementors must supply their own parse method 12 | const parse = (code: string) => 13 | acorn.parse(code, { sourceType: "module", ecmaVersion: "latest" }); 14 | 15 | // save us from repeatedly setting parse/warn/id 16 | const transformWrapper = (code: string, useLodashEs?: true) => 17 | transform({ 18 | code, 19 | id: "id-only-matters-for-sourcemap", 20 | parse, 21 | warn: warnMock, 22 | useLodashEs, 23 | }); 24 | 25 | test("when parse throws, transform throws", () => { 26 | const parseMock = jest.fn(() => { 27 | throw new Error("expected exception"); 28 | }); 29 | expect(() => 30 | transform({ 31 | code: "import { isNil } from 'lodash';", 32 | warn: warnMock, 33 | id: "random-code-id", 34 | parse: parseMock, 35 | }) 36 | ).toThrow(); 37 | }); 38 | 39 | describe("lodash transforms", () => { 40 | test("code without lodash is not parsed", () => { 41 | const parseMock = jest.fn(); 42 | expect( 43 | transform({ code: "hello world", parse: parseMock, id: "my-id" }) 44 | ).toEqual(UNCHANGED); 45 | expect(parseMock).not.toHaveBeenCalled(); 46 | }); 47 | 48 | test.each<[string, { cjs: string; es: string } | UNCHANGED]>([ 49 | [ 50 | `import { isNil } from 'lodash';`, 51 | { 52 | cjs: `import isNil from "lodash/isNil";`, 53 | es: `import { isNil } from "lodash-es";`, 54 | }, 55 | ], 56 | [ 57 | `import { isNil as nil } from 'lodash';`, 58 | { 59 | cjs: `import nil from "lodash/isNil";`, 60 | es: `import { isNil as nil } from "lodash-es";`, 61 | }, 62 | ], 63 | [ 64 | `import { isNil, isString } from 'lodash';`, 65 | { 66 | cjs: `import isNil from "lodash/isNil";\nimport isString from "lodash/isString";`, 67 | es: `import { isNil } from "lodash-es";\nimport { isString } from "lodash-es";`, 68 | }, 69 | ], 70 | [ 71 | `import { isNil } from 'lodash';\nimport { isString } from 'lodash';`, 72 | { 73 | cjs: `import isNil from "lodash/isNil";\nimport isString from "lodash/isString";`, 74 | es: `import { isNil } from "lodash-es";\nimport { isString } from "lodash-es";`, 75 | }, 76 | ], 77 | [ 78 | `import { isNil, isString as str } from 'lodash';`, 79 | { 80 | cjs: `import isNil from "lodash/isNil";\nimport str from "lodash/isString";`, 81 | es: `import { isNil } from "lodash-es";\nimport { isString as str } from "lodash-es";`, 82 | }, 83 | ], 84 | [ 85 | `import { some } from 'lodash/fp';`, 86 | { 87 | cjs: `import some from "lodash/fp/some";`, 88 | es: `import { some } from "lodash-es/fp";`, 89 | }, 90 | ], 91 | // nothing to transform 92 | [`const k = 1;`, UNCHANGED], 93 | [``, UNCHANGED], 94 | [`function hello() {}`, UNCHANGED], 95 | // ignore non-lodash imports 96 | [`import hello from "world";`, UNCHANGED], 97 | // ignore lodash-es imports 98 | [`import { isNil } from "lodash-es";`, UNCHANGED], 99 | // ignore full lodash imports 100 | [`import _ from "lodash";`, UNCHANGED], 101 | [`import lodash from "lodash";`, UNCHANGED], 102 | [`import lodash from "lodash/fp";`, UNCHANGED], 103 | [`import * as lodash from "lodash";`, UNCHANGED], 104 | [`import * as lodash from "lodash/fp";`, UNCHANGED], 105 | // ignore already-optimized lodash imports 106 | [`import isNil from 'lodash/isNil';`, UNCHANGED], 107 | [`import extend from 'lodash/fp/extend';`, UNCHANGED], 108 | [`import { extend } from "lodash-es/fp";`, UNCHANGED], 109 | ])("%s", (input, expectedOutput) => { 110 | const output = { 111 | cjs: transformWrapper(input), 112 | es: transformWrapper(input, true), 113 | }; 114 | 115 | if (expectedOutput === UNCHANGED) { 116 | expect(output.cjs).toBeNull(); 117 | expect(output.es).toBeNull(); 118 | } else { 119 | for (const key of ["cjs", "es"] as const) { 120 | expect(output[key]).not.toEqual(UNCHANGED); 121 | const { code, map } = output[key] as CodeWithSourcemap; 122 | 123 | // verify actual output matches our expectation 124 | expect(code).toEqual(expectedOutput[key]); 125 | 126 | // verify the output is parsable code 127 | expect(() => 128 | acorn.parse(code, { ecmaVersion: "latest", sourceType: "module" }) 129 | ).not.toThrow(); 130 | 131 | // verify sourcemap exists 132 | expect(map.toString().length).toBeGreaterThan(0); 133 | } 134 | } 135 | }); 136 | 137 | describe("warn on incompatible imports", () => { 138 | test.each<[string, number]>([ 139 | // unsupported cases which should warn 140 | [`import lodash from "lodash";`, 1], 141 | [`import _ from "lodash";`, 1], 142 | [`import fp from "lodash/fp";`, 1], 143 | [`import * as lodash from "lodash";`, 1], 144 | // supported or no-op cases 145 | [`import { isNil } from "lodash/isNil";`, 0], 146 | [`import { isNil } from "lodash-es";`, 0], 147 | [`import { every } from "lodash/fp";`, 0], 148 | ])("%s", (input, expectWarnings) => { 149 | void transformWrapper(input); 150 | expect(warnMock).toHaveBeenCalledTimes(expectWarnings); 151 | }); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /packages/rollup-plugin/README.md: -------------------------------------------------------------------------------- 1 | # Optimize `lodash` imports with Rollup.js 2 | 3 | [![npm](https://img.shields.io/npm/v/@optimize-lodash/rollup-plugin)](https://www.npmjs.com/package/@optimize-lodash/rollup-plugin) 4 | ![node-current](https://img.shields.io/node/v/@optimize-lodash/rollup-plugin) 5 | ![npm peer dependency version](https://img.shields.io/npm/dependency-version/@optimize-lodash/rollup-plugin/peer/rollup) 6 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/kyle-johnson/rollup-plugin-optimize-lodash-imports/CI)](https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports/actions) 7 | ![LGTM Grade](https://img.shields.io/lgtm/grade/javascript/github/kyle-johnson/rollup-plugin-optimize-lodash-imports) 8 | [![license](https://img.shields.io/npm/l/@optimize-lodash/rollup-plugin)](https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports/blob/main/packages/rollup-plugin/LICENSE) 9 | [![Codecov](https://img.shields.io/codecov/c/github/kyle-johnson/rollup-plugin-optimize-lodash-imports?flag=rollup-plugin&label=coverage)](https://app.codecov.io/gh/kyle-johnson/rollup-plugin-optimize-lodash-imports/) 10 | ![GitHub last commit](https://img.shields.io/github/last-commit/kyle-johnson/rollup-plugin-optimize-lodash-imports) 11 | 12 | There are [multiple](https://github.com/webpack/webpack/issues/6925) [issues](https://github.com/lodash/lodash/issues/3839) [surrounding](https://github.com/rollup/rollup/issues/1403) [tree-shaking](https://github.com/rollup/rollup/issues/691) of lodash. Minifiers, even with dead-code elimination, cannot currently solve this problem. Check out the test showing that even with terser as a minifier, [this plugin can still reduce bundle size by 70%](https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports/blob/main/packages/rollup-plugin/tests/bundle-size.test.ts) for [an example input](https://github.com/kyle-johnson/rollup-plugin-optimize-lodash-imports/blob/main/packages/rollup-plugin/tests/fixtures/standard-and-fp.js). With this plugin, bundled code output will _only_ include the specific lodash methods your code requires. 13 | 14 | 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`. 15 | 16 | ### This input 17 | 18 | ```javascript 19 | import { isNil, isString } from "lodash"; 20 | import { padStart as padStartFp } from "lodash/fp"; 21 | ``` 22 | 23 | ### Becomes this output 24 | 25 | ```javascript 26 | import isNil from "lodash/isNil"; 27 | import isString from "lodash/isString"; 28 | import padStartFp from "lodash/fp/padStart"; 29 | ``` 30 | 31 | ## `useLodashEs` for ES Module Output 32 | 33 | While `lodash-es` is not usable from CommonJS modules, some projects use Rollup to create two outputs: one for ES and one for CommonJS. 34 | 35 | In this case, you can offer your users the best of both: 36 | 37 | ### Your source input 38 | 39 | ```javascript 40 | import { isNil } from "lodash"; 41 | ``` 42 | 43 | #### CommonJS output 44 | 45 | ```javascript 46 | import isNil from "lodash/isNil"; 47 | ``` 48 | 49 | #### ES output (with `useLodashEs: true`) 50 | 51 | ```javascript 52 | import { isNil } from "lodash-es"; 53 | ``` 54 | 55 | ## Usage 56 | 57 | ```javascript 58 | import { optimizeLodashImports } from "@optimize-lodash/rollup-plugin"; 59 | 60 | export default { 61 | input: "src/index.js", 62 | output: { 63 | dir: "dist", 64 | format: "cjs", 65 | }, 66 | plugins: [optimizeLodashImports()], 67 | }; 68 | ``` 69 | 70 | ## Options 71 | 72 | Configuration can be passed to the plugin as an object with the following keys: 73 | 74 | ### `exclude` 75 | 76 | Type: `String` | `Array[...String]`
77 | Default: `null` 78 | 79 | A [minimatch pattern](https://github.com/isaacs/minimatch), or array of patterns, which specifies the files in the build the plugin should _ignore_. By default no files are ignored. 80 | 81 | ### `include` 82 | 83 | Type: `String` | `Array[...String]`
84 | Default: `null` 85 | 86 | A [minimatch pattern](https://github.com/isaacs/minimatch), or array of patterns, which specifies the files in the build the plugin should operate on. By default all files are targeted. 87 | 88 | ### `useLodashEs` 89 | 90 | Type: `boolean`
91 | Default: `false` 92 | 93 | If `true`, the plugin will rewrite _lodash_ imports to use _lodash-es_. 94 | 95 | _Note: the build will fail if your Rollup output format is not also set to `es`!_ 96 | 97 | ## Limitations 98 | 99 | ### Default imports are not optimized 100 | 101 | Unlike [babel-plugin-lodash](https://github.com/lodash/babel-plugin-lodash), there is no support for optimizing the lodash default import, such as in this case: 102 | 103 | ```javascript 104 | // this import can't be optimized 105 | import _ from "lodash"; 106 | 107 | export function testX(x) { 108 | return _.isNil(x); 109 | } 110 | ``` 111 | 112 | The above code will not be optimized, and Rollup will print a warning. 113 | 114 | To avoid this, always import the specific method(s) you need: 115 | 116 | ```javascript 117 | // this import will be optimized 118 | import { isNil } from "lodash"; 119 | 120 | export function testX(x) { 121 | return isNil(x); 122 | } 123 | ``` 124 | 125 | ## Alternatives 126 | 127 | [`babel-plugin-lodash`](https://www.npmjs.com/package/babel-plugin-lodash) solves the issue for CommonJS outputs and modifies default imports as well. However, it doesn't enable transparent `lodash-es` use and may not make sense for projects using [@rollup/plugin-typescript](https://www.npmjs.com/package/@rollup/plugin-typescript) which don't wish to add a Babel step. 128 | 129 | Other alternatives include `eslint-plugin-lodash` with the [`import-scope` rule enabled](https://github.com/wix/eslint-plugin-lodash/blob/HEAD/docs/rules/import-scope.md). This works for CommonJS outputs, but may require manual effort to stay on top of imports. 130 | --------------------------------------------------------------------------------