Skip to content

Commit 9560a02

Browse files
committed
feat(copy): Add capability to transform copied files on the way
1 parent e5a8479 commit 9560a02

File tree

6 files changed

+184
-43
lines changed

6 files changed

+184
-43
lines changed

packages/esbuild-plugin-copy/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ ESBuild plugin for assets copy.
1212
- Control assets destination path freely
1313
- Support verbose output log
1414
- Run only once or only when assets changed
15+
- Transform files along the way
1516

1617
## Usage
1718

@@ -164,6 +165,31 @@ Watching Mode of this plugin is implemented using polling for being consistent w
164165
})();
165166
```
166167

168+
## Transform
169+
170+
You can provide a transform function to make changes to files along the way. The function will be passed the absolute path where the file is being copied from as well as the original content of the file as a `Buffer`. The transform function should return the final content of the file as a string or buffer.
171+
172+
```typescript
173+
const res = await build({
174+
plugins: [
175+
copy({
176+
assets: [
177+
{
178+
from: 'src/**/*',
179+
to: 'dist',
180+
transform: (fromPath, content) => {
181+
if (fromPath.endsWith('.js')) {
182+
content = `"use string"\n${content}`;
183+
}
184+
return content;
185+
},
186+
},
187+
],
188+
}),
189+
],
190+
});
191+
```
192+
167193
## Configurations
168194

169195
```typescript
@@ -191,6 +217,17 @@ export interface AssetPair {
191217
* @default false
192218
*/
193219
watch?: boolean | WatchOptions;
220+
221+
/**
222+
* transforms files before copying them to the destination path
223+
* `from` is he resolved source path of the current file
224+
*
225+
* @default false
226+
*/
227+
transform?: (
228+
from: string,
229+
content: Buffer
230+
) => Promise<string | Buffer> | string | Buffer;
194231
}
195232

196233
export interface Options {

packages/esbuild-plugin-copy/src/lib/esbuild-plugin-copy.ts

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import path from 'path';
22
import chalk from 'chalk';
33
import globby from 'globby';
44
import chokidar from 'chokidar';
5+
import fs from 'fs';
56

67
import { copyOperationHandler } from './handler';
78

@@ -113,7 +114,12 @@ export const copy = (options: Partial<Options> = {}): Plugin => {
113114
globalWatchControl = false;
114115
}
115116

116-
for (const { from, to, watch: localWatchControl } of formattedAssets) {
117+
for (const {
118+
from,
119+
to,
120+
watch: localWatchControl,
121+
transform,
122+
} of formattedAssets) {
117123
const useWatchModeForCurrentAssetPair =
118124
globalWatchControl || localWatchControl;
119125

@@ -138,20 +144,26 @@ export const copy = (options: Partial<Options> = {}): Plugin => {
138144
);
139145
}
140146

141-
const executor = () => {
147+
const executor = async () => {
148+
const copyPromises: Promise<void>[] = [];
149+
142150
for (const fromPath of deduplicatedPaths) {
143-
to.forEach((toPath) => {
144-
copyOperationHandler(
145-
outDirResolveFrom,
146-
from,
147-
fromPath,
148-
toPath,
149-
verbose,
150-
dryRun
151+
for (const toPath of to) {
152+
copyPromises.push(
153+
copyOperationHandler(
154+
outDirResolveFrom,
155+
from,
156+
fromPath,
157+
toPath,
158+
verbose,
159+
dryRun,
160+
transform
161+
)
151162
);
152-
});
163+
}
153164
}
154165

166+
await Promise.all(copyPromises);
155167
process.env[PLUGIN_EXECUTED_FLAG] = 'true';
156168
};
157169

@@ -163,7 +175,7 @@ export const copy = (options: Partial<Options> = {}): Plugin => {
163175
verbose
164176
);
165177

166-
executor();
178+
await executor();
167179

168180
const watcher = chokidar.watch(from, {
169181
disableGlobbing: false,
@@ -174,27 +186,30 @@ export const copy = (options: Partial<Options> = {}): Plugin => {
174186
: {}),
175187
});
176188

177-
watcher.on('change', (fromPath) => {
189+
watcher.on('change', async (fromPath) => {
178190
verboseLog(
179191
`[File Changed] File ${chalk.white(
180192
fromPath
181193
)} changed, copy operation triggered.`,
182194
verbose
183195
);
184196

185-
to.forEach((toPath) => {
186-
copyOperationHandler(
187-
outDirResolveFrom,
188-
from,
189-
fromPath,
190-
toPath,
191-
verbose,
192-
dryRun
193-
);
194-
});
197+
await Promise.all(
198+
to.map((toPath) =>
199+
copyOperationHandler(
200+
outDirResolveFrom,
201+
from,
202+
fromPath,
203+
toPath,
204+
verbose,
205+
dryRun,
206+
transform
207+
)
208+
)
209+
);
195210
});
196211
} else {
197-
executor();
212+
await executor();
198213
}
199214
}
200215
});

packages/esbuild-plugin-copy/src/lib/handler.ts

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
33
import chalk from 'chalk';
44

55
import { verboseLog } from './utils';
6+
import { AssetPair } from './typings';
67

78
/**
89
*
@@ -11,19 +12,16 @@ import { verboseLog } from './utils';
1112
* @param globbedFromPath the globbed file from path, which are globbed from rawFromPath
1213
* @param baseToPath the original asset.to value from user config, which will be resolved with outDirResolveFrom option
1314
* @param verbose verbose logging
14-
* @param dryRun dry run mode
1515
* @returns
1616
*/
17-
export function copyOperationHandler(
17+
function resolvePaths(
1818
outDirResolveFrom: string,
1919
rawFromPath: string[],
2020
globbedFromPath: string,
2121
baseToPath: string,
22-
23-
verbose = false,
24-
dryRun = false
25-
) {
26-
for (const rawFrom of rawFromPath) {
22+
verbose = false
23+
): [src: string, dest: string][] {
24+
return rawFromPath.map((rawFrom) => {
2725
// only support from dir like: /**/*(.ext)
2826
const { dir } = path.parse(rawFrom);
2927

@@ -73,15 +71,65 @@ export function copyOperationHandler(
7371
baseToPath
7472
);
7573

76-
dryRun ? void 0 : fs.ensureDirSync(path.dirname(composedDistDirPath));
74+
return [sourcePath, composedDistDirPath];
75+
});
76+
}
7777

78-
dryRun ? void 0 : fs.copyFileSync(sourcePath, composedDistDirPath);
78+
/**
79+
*
80+
* @param outDirResolveFrom the base destination dir that will resolve with asset.to value
81+
* @param rawFromPath the original asset.from value from user config
82+
* @param globbedFromPath the globbed file from path, which are globbed from rawFromPath
83+
* @param baseToPath the original asset.to value from user config, which will be resolved with outDirResolveFrom option
84+
* @param verbose verbose logging
85+
* @param dryRun dry run mode
86+
* @param transform middleman transform function
87+
* @returns
88+
*/
89+
export async function copyOperationHandler(
90+
outDirResolveFrom: string,
91+
rawFromPath: string[],
92+
globbedFromPath: string,
93+
baseToPath: string,
94+
95+
verbose = false,
96+
dryRun = false,
97+
transform: AssetPair['transform'] = null
98+
) {
99+
const resolvedPaths = resolvePaths(
100+
outDirResolveFrom,
101+
rawFromPath,
102+
globbedFromPath,
103+
baseToPath,
104+
verbose
105+
);
106+
107+
const copyPromises = resolvedPaths.map(async ([src, dest]) => {
108+
if (dryRun) {
109+
verboseLog(
110+
`${chalk.white('[DryRun] ')}File copied: ${chalk.white(
111+
src
112+
)} -> ${chalk.white(dest)}`,
113+
verbose
114+
);
115+
return;
116+
}
117+
118+
await fs.ensureDir(path.dirname(dest));
119+
120+
if (transform) {
121+
const sourceContent = await fs.readFile(src);
122+
const finalContent = await transform(src, sourceContent);
123+
await fs.writeFile(dest, finalContent);
124+
} else {
125+
await fs.copyFile(src, dest);
126+
}
79127

80128
verboseLog(
81-
`${dryRun ? chalk.white('[DryRun] ') : ''}File copied: ${chalk.white(
82-
sourcePath
83-
)} -> ${chalk.white(composedDistDirPath)}`,
129+
`File copied: ${chalk.white(src)} -> ${chalk.white(dest)}`,
84130
verbose
85131
);
86-
}
132+
});
133+
134+
await Promise.all(copyPromises);
87135
}

packages/esbuild-plugin-copy/src/lib/typings.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ export interface AssetPair {
2222
* @default false
2323
*/
2424
watch?: boolean | WatchOptions;
25+
26+
/**
27+
* transforms files before copying them to the destination path
28+
* `from` is he resolved source path of the current file
29+
*
30+
* @default false
31+
*/
32+
transform?: (
33+
from: string,
34+
content: Buffer
35+
) => Promise<string | Buffer> | string | Buffer;
2536
}
2637

2738
export interface Options {

packages/esbuild-plugin-copy/src/lib/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ export function verboseLog(msg: string, verbose: boolean, lineBefore = false) {
1515
export function formatAssets(assets: MaybeArray<AssetPair>) {
1616
return ensureArray(assets)
1717
.filter((asset) => asset.from && asset.to)
18-
.map(({ from, to, watch }) => ({
18+
.map(({ from, to, watch, transform }) => ({
1919
from: ensureArray(from),
2020
to: ensureArray(to),
2121
watch: watch ?? false,
22+
transform,
2223
}));
2324
}
2425

packages/esbuild-plugin-copy/tests/plugin.spec.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
PLUGIN_EXECUTED_FLAG,
1212
} from '../src/lib/utils';
1313

14-
import type { Options } from '../src/lib/typings';
14+
import type { AssetPair, Options } from '../src/lib/typings';
1515

1616
const FixtureDir = path.resolve(__dirname, './fixtures');
1717

@@ -46,7 +46,7 @@ describe('CopyPlugin:Core', async () => {
4646
afterEach(() => {
4747
delete process.env[PLUGIN_EXECUTED_FLAG];
4848
});
49-
it('should works for from path: /**<1>', async () => {
49+
it('should work for from path: /**<1>', async () => {
5050
const outDir = tmp.dirSync().name;
5151
const outAssetsDir = tmp.dirSync().name;
5252

@@ -78,7 +78,7 @@ describe('CopyPlugin:Core', async () => {
7878
expect(d3).toEqual(['deep.txt']);
7979
});
8080

81-
it('should works for from path: /**<2>', async () => {
81+
it('should work for from path: /**<2>', async () => {
8282
const outDir = tmp.dirSync().name;
8383
const outAssetsDir = tmp.dirSync().name;
8484

@@ -105,7 +105,7 @@ describe('CopyPlugin:Core', async () => {
105105
expect(d2).toEqual(['content.js']);
106106
});
107107

108-
it('should works for from path: /**<3>', async () => {
108+
it('should work for from path: /**<3>', async () => {
109109
const outDir = tmp.dirSync().name;
110110
const outAssetsDir = tmp.dirSync().name;
111111

@@ -372,7 +372,8 @@ describe('CopyPlugin:Core', async () => {
372372

373373
expect(d1).toEqual(['hello.txt', 'index.js']);
374374
});
375-
it.only('should copy from file to file with nested dest dir', async () => {
375+
376+
it('should copy from file to file with nested dest dir', async () => {
376377
const outDir = tmp.dirSync().name;
377378

378379
await builder(
@@ -393,6 +394,34 @@ describe('CopyPlugin:Core', async () => {
393394

394395
expect(d1).toEqual(['hello.txt']);
395396
});
397+
398+
it('should transform file when provided a transform function', async () => {
399+
const outDir = tmp.dirSync().name;
400+
401+
const suffix = 'wondeful';
402+
403+
const transform: AssetPair['transform'] = (_, content) => {
404+
return content.toString() + suffix;
405+
};
406+
407+
await builder(
408+
outDir,
409+
{ outdir: outDir },
410+
{
411+
assets: {
412+
from: path.resolve(__dirname, './fixtures/assets/note.txt'),
413+
to: 'hello.txt',
414+
transform,
415+
},
416+
resolveFrom: outDir,
417+
verbose: false,
418+
dryRun: false,
419+
}
420+
);
421+
422+
const result = fs.readFileSync(path.join(outDir, 'hello.txt'), 'utf8');
423+
expect(result.endsWith(suffix)).to.be.true;
424+
});
396425
});
397426

398427
describe('CopyPlugin:Utils', async () => {

0 commit comments

Comments
 (0)