├── .gitignore ├── .npmignore ├── LICENSE.txt ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src └── index.ts ├── tests └── index.spec.ts └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn-error.log 3 | tmp 4 | dist 5 | yarn-error.log 6 | test-demo -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | tests/ 3 | dist/*.map 4 | tmp/ 5 | examples/ 6 | /yarn.lock -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Andreas Heissenberger 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # esbuild-plugin-text-replace 2 | 3 | Replace content before bundling with support for Filefilter, Namespace and Regex. 4 | 5 | ## Install 6 | 7 | $ npm install --save-dev esbuild-plugin-text-replace 8 | 9 | **Hint:** Node >=10.1.0 for fs.promise 10 | 11 | ## Usage 12 | 13 | **as a plugin** 14 | ```js 15 | import esbuild from 'esbuild' 16 | import textReplace from 'esbuild-plugin-text-replace' 17 | 18 | await esbuild.build( 19 | { 20 | entryPoints: ['./test-build-input'], 21 | outfile: 'test-build-out.js', 22 | plugins: [ 23 | textReplace( 24 | { 25 | include: /mypackage\/dist\/loader\.js$/, 26 | pattern:[ 27 | ['const installRetry','let installRetry'], 28 | [/const\s+{\s*textReplace\s*}\s*=\s*require\s*\(\s*'esbuild-plugin-text-replace'\s*\)\s*;/g , "'import textReplace from 'esbuild-plugin-text-replace'"] 29 | ] 30 | } 31 | ) 32 | ], 33 | } 34 | ) 35 | ``` 36 | 37 | **as part of a pipe** 38 | ```js 39 | import esbuild from 'esbuild'; 40 | import pipe from 'esbuild-plugin-pipe'; 41 | import textReplace from 'esbuild-plugin-text-replace'; 42 | 43 | await esbuild.build( 44 | { 45 | entryPoints: ['./test-build-input'], 46 | outfile: 'test-build-out.js', 47 | bundle: true, 48 | plugins: [ 49 | pipe({ 50 | filter: /.*/, 51 | namespace: '', 52 | plugins: [ 53 | textReplace( 54 | { 55 | include: /mypackage\/dist\/loader\.js$/, 56 | pattern:[ 57 | ['const installRetry','let installRetry'], 58 | [/const\s+{\s*textReplace\s*}\s*=\s*require\s*\(\s*'esbuild-plugin-text-replace'\s*\)\s*;/g , "'import textReplace from 'esbuild-plugin-text-replace'"] 59 | ] 60 | } 61 | ) 62 | ] 63 | }) 64 | ] 65 | } 66 | ) 67 | ``` 68 | 69 | ## Options 70 | 71 | ### `include` 72 | 73 | Filter filepath by regex. 74 | 75 | Type: `RegExp` 76 | Default: `/.*/` 77 | 78 | > **Note:** Try to never use the default value as this is has a huge impact on speed if all files are matched! 79 | 80 | ### `namespace` 81 | 82 | Type: `String` 83 | Default: all 84 | 85 | More info about [esbuild namespaces](https://esbuild.github.io/plugins/#namespaces) 86 | 87 | ### `pattern` 88 | 89 | Search with Text or Regex and replace the found content with a string. 90 | 91 | Type: `Array` 92 | Default: `[]` 93 | 94 | All information about the [replaceAll regex Options and replacer functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replaceAll). 95 | 96 | 97 | **Examples:** 98 | ```js 99 | [ 100 | // transform 2020-10-02 to 02.10.2020 101 | [/(\d{4})-(\d{2})-(\d{2})/g , (match,p1,p2,p3,offset,wholeString)=>`${p3}.${p2}.${p1}`], 102 | ['__buildVersion' , '"1.1.1"'], 103 | [/(\s*)const(\s+a\s*=\s*1[\s;\n])/g, '$1let$2'] 104 | ] 105 | ``` 106 | > **Note:** `/g` for globale replacement is a must requirement 107 | 108 | ## History 109 | 110 | * 1.3.0 **pipe mode only:** Use the regex from parameter include to transform only files which match 111 | * 1.2.0 Add [esbuild pipe](https://github.com/nativew/esbuild-plugin-pipe) support 112 | * 1.1.3 Initial relase 113 | ## Roadmap 114 | 115 | - [X] tests 116 | - [ ] speed tests 117 | 118 | ## Contribution 119 | 120 | Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are greatly appreciated. 121 | 122 | 1. Fork the Project 123 | 1. Create your Feature Branch (git checkout -b feature/AmazingFeature) 124 | 1. Commit your Changes (git commit -m 'Add some AmazingFeature') 125 | 1. Push to the Branch (git push origin feature/AmazingFeature) 126 | 1. Open a Pull Request 127 | 128 | ## Built With 129 | 130 | - [microbundle](https://github.com/developit/microbundle) 131 | 132 | ## License 133 | 134 | Distributed under the "bsd-2-clause" License. See [LICENSE.txt](LICENSE.txt) for more information. 135 | 136 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esbuild-plugin-text-replace", 3 | "version": "1.3.0", 4 | "main": "dist/index.js", 5 | "module": "dist/index.esm.mjs", 6 | "exports": { 7 | ".": { 8 | "import": "./dist/index.js", 9 | "require": "./dist/index.js", 10 | "default": "./dist/index.esm.mjs" 11 | } 12 | }, 13 | "source": "src/index.ts", 14 | "types": "dist/index.d.ts", 15 | "license": "bsd-2-clause", 16 | "description": "Replace Text with Regex in files before bundling.", 17 | "keywords": [ 18 | "esbuild", 19 | "plugin", 20 | "replace", 21 | "regex", 22 | "text", 23 | "module" 24 | ], 25 | "author": "Andreas Heissenberger ", 26 | "homepage": "https://github.com/aheissenberger/esbuild-plugin-text-replace", 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/aheissenberger/esbuild-plugin-text-replace.git" 30 | }, 31 | "devDependencies": { 32 | "@types/jest": "^29.5.0", 33 | "esbuild": "^0.17.14", 34 | "esbuild-plugin-pipe": "^0.2.0", 35 | "jest": "^29.5.0", 36 | "microbundle": "^0.15.1", 37 | "mock-fs": "^5.2.0", 38 | "ts-jest": "^29.0.5" 39 | }, 40 | "scripts": { 41 | "build": "microbundle build -i src/index.ts -f es,cjs --sourcemap", 42 | "dev": "microbundle watch src/index.ts -f es,cjs --no-compress --sourcemap", 43 | "prepublishOnly": "microbundle build -i src/index.ts -f es,cjs --compress --no-sourcemap", 44 | "test": "jest" 45 | }, 46 | "dependencies": { 47 | "ts-replace-all": "^1.0.0" 48 | }, 49 | "engines": { 50 | "node": ">=10.1.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as fsold from 'fs' 2 | import 'ts-replace-all' 3 | 4 | type replacePattern = [RegExp | string, string | any] 5 | 6 | const fs = fsold.promises 7 | export interface optionI { 8 | include?: RegExp, 9 | namespace?: string, 10 | pattern: replacePattern[] 11 | } 12 | 13 | interface pipeI { 14 | transform?: null | { 15 | contents: string 16 | args: { path: string, namespace: string, suffix: string, pluginData: any } 17 | } 18 | } 19 | 20 | const generateFilter = (options: optionI) => { 21 | if (Object.prototype.toString.call(options.include) !== '[object RegExp]') { 22 | console.warn(`Plugin "textReplace": Options.include must be a RegExp object, but gets an '${typeof options.include}' type. \nThis request will match ANY file!`); 23 | return /.*/ 24 | } 25 | return options.include; 26 | } 27 | 28 | const replaceWithPattern = (source: string, pattern: replacePattern[]) => { 29 | let content = source.slice() 30 | pattern.forEach(([regex, replacer]) => { 31 | content = content.replaceAll(regex, replacer) 32 | }); 33 | return content 34 | } 35 | 36 | const setupPlugin = (filter: RegExp|undefined, namespace: string | undefined, pattern: replacePattern[], errors: any[]) => { 37 | return { 38 | name: 'textReplace', 39 | setup(build, { transform = null }: pipeI = {}): { contents: string } | undefined { 40 | 41 | if (transform) { 42 | if (transform?.contents && (namespace === undefined || namespace === '' || transform?.args?.namespace === namespace) && (filter === undefined || filter.test(transform?.args?.path))) { 43 | const source = transform.contents 44 | const contents = replaceWithPattern(source, pattern) 45 | return { contents }; 46 | } else { 47 | return { contents: transform.contents }; 48 | } 49 | }; 50 | 51 | build.onLoad({ filter, namespace }, async ({ path }) => { 52 | const source = await fs.readFile(path, "utf8"); 53 | const contents = replaceWithPattern(source, pattern) 54 | return { contents }; 55 | }) 56 | } 57 | } 58 | } 59 | 60 | function textReplace(options: optionI = { 61 | include: /.*/, 62 | pattern: [] 63 | }) { 64 | let errors = [] 65 | const filter = generateFilter(options) 66 | const namespace = options?.namespace 67 | 68 | if (!Array.isArray(options.pattern)) { 69 | throw new Error(`Plugin "textReplace": Options.pattern must be an Array!`) 70 | 71 | } 72 | if (options.pattern.length === 0) { 73 | throw new Error(`Plugin "textReplace": Options.pattern must not be an empty Array!`) 74 | } 75 | 76 | return setupPlugin(filter, namespace, options.pattern, errors) 77 | } 78 | export default textReplace -------------------------------------------------------------------------------- /tests/index.spec.ts: -------------------------------------------------------------------------------- 1 | import textReplace, { optionI } from '../src/index' 2 | import * as fsmock from 'mock-fs' 3 | 4 | const buildMock = (path, cb) => ({ 5 | onLoad: async (para, callback) => { 6 | const content = await callback({ path }) 7 | cb(content) 8 | } 9 | }) 10 | 11 | describe('configuration has', () => { 12 | 13 | beforeEach(() => { 14 | fsmock( 15 | { 16 | 'loader.js': 'A', 17 | 'lib.js': 'const a=1; a=2;', 18 | 'path/to/fake/date.js': '_2020-02-01_' 19 | } 20 | ) 21 | }) 22 | 23 | afterEach(fsmock.restore); 24 | 25 | it('is minimal pattern', (doneTest) => { 26 | const setup: optionI = { 27 | pattern: [ 28 | ['A', 'B'] 29 | ] 30 | } 31 | const expected = 'B' 32 | 33 | const plugin = textReplace(setup) 34 | 35 | plugin.setup(buildMock('loader.js', (data) => { 36 | expect(data.contents).toBe(expected) 37 | doneTest() 38 | })) 39 | 40 | }) 41 | 42 | it('empty pattern array', () => { 43 | const setup: optionI = { 44 | pattern: [ 45 | 46 | ] 47 | } 48 | 49 | expect(() => { 50 | const plugin = textReplace(setup) 51 | }).toThrow(); 52 | }) 53 | 54 | it('regex pattern', (doneTest) => { 55 | const setup: optionI = { 56 | pattern: [ 57 | [/(\s*)const(\s+a\s*=\s*1[\s;\n])/g, '$1let$2'] 58 | ] 59 | } 60 | const expected = 'let a=1; a=2;' 61 | 62 | const plugin = textReplace(setup) 63 | 64 | plugin.setup(buildMock('lib.js', (data) => { 65 | expect(data.contents).toBe(expected) 66 | doneTest() 67 | })) 68 | 69 | }) 70 | 71 | it('function pattern', (doneTest) => { 72 | const setup: optionI = { 73 | include: /fake\/date\.js$/, 74 | pattern: [ 75 | 76 | [/(\d{4})-(\d{2})-(\d{2})/g, (match, p1, p2, p3, offset, wholeString) => `${p3}.${p2}.${p1}`] 77 | ] 78 | } 79 | const expected = '_01.02.2020_' 80 | 81 | const plugin = textReplace(setup) 82 | 83 | plugin.setup(buildMock('path/to/fake/date.js', (data) => { 84 | expect(data.contents).toBe(expected) 85 | doneTest() 86 | })) 87 | 88 | }) 89 | describe('pipe', () => { 90 | it('pipe ', () => { 91 | const setup: optionI = { 92 | pattern: [ 93 | ['A', 'B'] 94 | ] 95 | } 96 | const expected = 'B' 97 | 98 | const plugin = textReplace(setup) 99 | 100 | const result = plugin.setup(null, { 101 | transform: { 102 | args: { 103 | path: '/directory/loader.js', 104 | namespace: 'file', 105 | suffix: '', 106 | pluginData: undefined 107 | }, 108 | contents: 'A' 109 | } 110 | }) 111 | expect(result?.contents).toBe(expected) 112 | }) 113 | 114 | it('pipe with file included', () => { 115 | const setup: optionI = { 116 | include: /loader.js$/, 117 | pattern: [ 118 | ['A', 'B'] 119 | ] 120 | } 121 | const expected = 'B' 122 | 123 | const plugin = textReplace(setup) 124 | 125 | const result = plugin.setup(null, { 126 | transform: { 127 | args: { 128 | path: '/directory/loader.js', 129 | namespace: 'file', 130 | suffix: '', 131 | pluginData: undefined 132 | }, 133 | contents: 'A' 134 | } 135 | }) 136 | expect(result?.contents).toBe(expected) 137 | }) 138 | 139 | it('pipe with file included namespace file', () => { 140 | const setup: optionI = { 141 | namespace: 'file', 142 | include: /loader.js$/, 143 | pattern: [ 144 | ['A', 'B'] 145 | ] 146 | } 147 | const expected = 'B' 148 | 149 | const plugin = textReplace(setup) 150 | 151 | const result = plugin.setup(null, { 152 | transform: { 153 | args: { 154 | path: '/directory/loader.js', 155 | namespace: 'file', 156 | suffix: '', 157 | pluginData: undefined 158 | }, 159 | contents: 'A' 160 | } 161 | }) 162 | expect(result?.contents).toBe(expected) 163 | }) 164 | 165 | it('pipe with file included namespace file', () => { 166 | const setup: optionI = { 167 | namespace: '', 168 | include: /loader.js$/, 169 | pattern: [ 170 | ['A', 'B'] 171 | ] 172 | } 173 | const expected = 'B' 174 | 175 | const plugin = textReplace(setup) 176 | 177 | const result = plugin.setup(null, { 178 | transform: { 179 | args: { 180 | path: '/directory/loader.js', 181 | namespace: 'file', 182 | suffix: '', 183 | pluginData: undefined 184 | }, 185 | contents: 'A' 186 | } 187 | }) 188 | expect(result?.contents).toBe(expected) 189 | }) 190 | 191 | it('pipe with file included namespace http', () => { 192 | const setup: optionI = { 193 | namespace: 'http', 194 | include: /loader.js$/, 195 | pattern: [ 196 | ['A', 'B'] 197 | ] 198 | } 199 | const expected = 'A' // no transformation 200 | 201 | const plugin = textReplace(setup) 202 | 203 | const result = plugin.setup(null, { 204 | transform: { 205 | args: { 206 | path: '/directory/loader.js', 207 | namespace: 'file', 208 | suffix: '', 209 | pluginData: undefined 210 | }, 211 | contents: 'A' 212 | } 213 | }) 214 | expect(result?.contents).toBe(expected) 215 | }) 216 | 217 | it('pipe with file not include', () => { 218 | const setup: optionI = { 219 | include: /notextists.js$/, 220 | pattern: [ 221 | ['A', 'B'] 222 | ] 223 | } 224 | const expected = 'A' // no transformation 225 | 226 | const plugin = textReplace(setup) 227 | 228 | const result = plugin.setup(null, { 229 | transform: { 230 | args: { 231 | path: '/directory/loader.js', 232 | namespace: 'file', 233 | suffix: '', 234 | pluginData: undefined 235 | }, 236 | contents: 'A' 237 | } 238 | }) 239 | expect(result?.contents).toBe(expected) 240 | }) 241 | }) 242 | }) --------------------------------------------------------------------------------