diff --git a/README.md b/README.md index ece50ef..f53f5c2 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ npm install native-copyfiles -D -E, --error throw error if nothing is copied [boolean] -V, --verbose print more information to console [boolean] -F, --follow follow symbolic links [boolean] - -v, --version Show version number [boolean] - -h, --help Show help [boolean] + -v, --version show version number [boolean] + -h, --help show help [boolean] ``` > [!NOTE] @@ -122,6 +122,66 @@ copyfiles input/original.txt output/renamed.txt If the destination path is a directory, the file will be copied into that directory as usual. If the destination path is a filename, the file will be copied and renamed. +--- + +### Rename Multiple Files During Copy + +#### 1. Rename Using Glob Patterns + +You can use a wildcard (`*`) in the destination to rename files dynamically. For example, to copy all `.css` files and change their extension to `.scss`: + +```bash +copyfiles "input/**/*.css" "output/*.scss" +``` + +This will copy: + +- `input/foo.css` → `output/foo.scss` +- `input/bar/baz.css` → `output/bar/baz.scss` + +The `*` in the destination is replaced with the base filename from the source. +You can combine this with `--flat` or `--up` to control the output structure. + +#### 2. Rename Using a Callback (JavaScript API) + +For advanced renaming, you can use the `rename` callback option in the API. +This function receives the source and destination path and should return the new destination path. + +**Example: Change extension to `.scss` using a callback** + +```js +import { copyfiles } from 'native-copyfiles'; + +copyfiles(['input/**/*.css', 'output'], { + flat: true, + rename: (src, dest) => dest.replace(/\.css$/, '.scss') +}, (err) => { + // All files like input/foo.css → output/foo.scss +}); +``` + +**Example: Prefix all filenames with `renamed-` but keep the extension** + +```js +copyfiles(['input/**/*.css', 'output'], { + up: 1, + rename: (src, dest) => dest.replace(/([^/\\]+)\.css$/, 'renamed-$1.css') +}, (err) => { + // input/foo.css → output/renamed-foo.css + // input/bar/baz.css → output/bar/renamed-baz.css +}); +``` + +The `rename` callback gives you full control over the output filename and path. + +> **Tip:** +> You can use either the glob pattern approach or the `rename` callback, or even combine them for advanced scenarios! + +> [!NOTE] +> If you use both a destination glob pattern (e.g. `output/*.ext`) and a `rename` callback, the glob pattern is applied first and then the `rename` callback is executed last on the computed destination path. This allows you to combine both features for advanced renaming scenarios. + +--- + ### JavaScript API ```js @@ -136,11 +196,12 @@ and finally the third and last argument is a callback function which is executed ```js { - verbose: bool, // enable debug messages - up: number, // -u value - exclude: string, // exclude pattern - all: bool, // include dot files - follow: bool, // Follow symlinked directories when expanding ** patterns - error: bool // raise errors if no files copied + verbose: bool, // enable debug messages + up: number, // -u value + exclude: string, // exclude pattern + all: bool, // include dot files + follow: bool, // follow symlinked directories when expanding ** patterns + error: bool // raise errors if no files copied + rename: (src, dest) => string; // callback to transform the destination filename(s) } -``` +``` \ No newline at end of file diff --git a/src/__tests__/cli.spec.ts b/src/__tests__/cli.spec.ts index 26e188a..7c7fe6b 100644 --- a/src/__tests__/cli.spec.ts +++ b/src/__tests__/cli.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, readdir, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, readdir, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { createDir } from '../index'; @@ -62,17 +62,17 @@ describe('copyfiles', () => { setTimeout(check, 50); return; } - readdir('output2/input2', (err, files) => { - try { - expect(err).toBeNull(); + try { + setTimeout(() => { + const files = readdirSync('output2/input2'); expect(files).toEqual(['a.txt', 'b.txt']); exitSpy.mockRestore(); done(); - } catch (e) { - exitSpy.mockRestore(); - done(e); - } - }); + }, 50); + } catch (e) { + exitSpy.mockRestore(); + done(e); + } }; check(); }) diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts index 5729c2e..31009b5 100644 --- a/src/__tests__/index.spec.ts +++ b/src/__tests__/index.spec.ts @@ -1,4 +1,5 @@ -import { mkdirSync, readdir, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readdir, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { Readable } from 'node:stream'; import { globSync } from 'tinyglobby'; import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; @@ -13,7 +14,6 @@ vi.mock('node:fs', async () => { ...actual, createReadStream: (...args: any[]) => { if (shouldMockReadError) { - const { Readable } = require('node:stream'); const stream = new Readable({ read() { } }); setImmediate(() => stream.emit('error', error)); return stream; @@ -24,7 +24,7 @@ vi.mock('node:fs', async () => { }; }); -async function cleanupFolders() { +function cleanupFolders() { try { rmSync('input', { recursive: true, force: true }); rmSync('output', { recursive: true, force: true }); @@ -32,12 +32,12 @@ async function cleanupFolders() { } describe('copyfiles', () => { - afterEach(async () => { - await cleanupFolders(); + afterEach(() => { + cleanupFolders(); }); - afterAll(async () => { - await cleanupFolders(); + afterAll(() => { + cleanupFolders(); }); beforeEach(() => { @@ -62,8 +62,8 @@ describe('copyfiles', () => { writeFileSync('input/a.txt', 'a'); writeFileSync('input/b.txt', 'b'); writeFileSync('input/c.js', 'c'); - copyfiles(['input/*.txt', 'output'], {}, (err) => { - readdir('output/input', async (err, files) => { + copyfiles(['input/*.txt', 'output'], {}, () => { + readdir('output/input', async (_err, files) => { expect(files).toEqual(['a.txt', 'b.txt']); done(); }); @@ -76,8 +76,8 @@ describe('copyfiles', () => { }); writeFileSync('input/b.txt', 'b'); writeFileSync('input/c.js', 'c'); - copyfiles(['input/*.txt', 'output'], {}, (err) => { - readdir('output/input', (err, files) => { + copyfiles(['input/*.txt', 'output'], {}, () => { + readdir('output/input', (_err, files) => { expect(files).toEqual(['a.txt', 'b.txt']); // 'correct mode' // expect(statSync('output/input/a.txt').mode).toBe(33261); @@ -94,7 +94,7 @@ describe('copyfiles', () => { copyfiles(['input/*.txt', 'output'], { exclude: ['**/*.js.txt', '**/*.ps.txt'] }, (err) => { - readdir('output/input', (err, files) => { + readdir('output/input', (_err, files) => { expect(files).toEqual(['a.txt', 'b.txt']); done(); }); @@ -113,8 +113,8 @@ describe('copyfiles', () => { writeFileSync('input/a.txt', 'a'); writeFileSync('input/b.txt', 'b'); writeFileSync('input/.c.txt', 'c'); - copyfiles(['input/*.txt', 'output'], { all: true }, (err) => { - readdir('output/input', (err, files) => { + copyfiles(['input/*.txt', 'output'], { all: true }, () => { + readdir('output/input', (_err, files) => { expect(files).toEqual(['.c.txt', 'a.txt', 'b.txt']); done(); }); @@ -125,8 +125,8 @@ describe('copyfiles', () => { writeFileSync('input/a.txt', 'a'); writeFileSync('input/b.txt', 'b'); writeFileSync('input/c.js', 'c'); - copyfiles(['input/*.txt', 'output'], { up: 1 }, (err) => { - readdir('output', (err, files) => { + copyfiles(['input/*.txt', 'output'], { up: 1 }, () => { + readdir('output', (_err, files) => { expect(files).toEqual(['a.txt', 'b.txt']); done(); }); @@ -134,12 +134,14 @@ describe('copyfiles', () => { })); test('with up true', () => new Promise((done: any) => { + createDir('input/deep'); writeFileSync('input/a.txt', 'a'); writeFileSync('input/b.txt', 'b'); writeFileSync('input/c.js', 'c'); - copyfiles(['input/*.txt', 'output'], { up: true }, (err) => { - readdir('output', (err, files) => { - expect(files).toEqual(['a.txt', 'b.txt']); + writeFileSync('input/deep/d.txt', 'd'); + copyfiles(['input/**/*.txt', 'output'], { up: true }, () => { + readdir('output', (_err, files) => { + expect(files).toEqual(['a.txt', 'b.txt', 'd.txt']); done(); }); }); @@ -149,8 +151,8 @@ describe('copyfiles', () => { writeFileSync('input/other/a.txt', 'a'); writeFileSync('input/other/b.txt', 'b'); writeFileSync('input/other/c.js', 'c'); - copyfiles(['input/**/*.txt', 'output'], { up: 2 }, (err) => { - readdir('output', (err, files) => { + copyfiles(['input/**/*.txt', 'output'], { up: 2 }, () => { + readdir('output', (_err, files) => { expect(files).toEqual(['a.txt', 'b.txt']); done(); }); @@ -173,8 +175,8 @@ describe('copyfiles', () => { writeFileSync('input/other/a.txt', 'a'); writeFileSync('input/b.txt', 'b'); writeFileSync('input/other/c.js', 'c'); - copyfiles(['input/**/*.txt', 'output'], { flat: true }, (err) => { - readdir('output', (err, files) => { + copyfiles(['input/**/*.txt', 'output'], { flat: true }, () => { + readdir('output', (_err, files) => { expect(files).toEqual(['a.txt', 'b.txt']); done(); }); @@ -188,7 +190,7 @@ describe('copyfiles', () => { writeFileSync('input/origin/inner/a.txt', 'a'); writeFileSync('input/origin/inner/b.txt', 'b'); symlinkSync('origin', 'input/dest'); - copyfiles(['input/**/*.txt', 'output'], { up: 1, follow: true }, (err) => { + copyfiles(['input/**/*.txt', 'output'], { up: 1, follow: true }, () => { const files = globSync('output/**/*.txt'); expect(new Set(files)).toEqual(new Set(['output/a.txt', 'output/b.txt'])); }); @@ -200,8 +202,8 @@ describe('copyfiles', () => { writeFileSync('input/other/a.txt', 'a'); writeFileSync('input/b.txt', 'b'); writeFileSync('input/other/c.js', 'c'); - copyfiles(['input/**/*.txt', 'output'], { flat: true, verbose: true }, (err) => { - readdir('output', (err, files) => { + copyfiles(['input/**/*.txt', 'output'], { flat: true, verbose: true }, () => { + readdir('output', (_err, files) => { expect(files).toEqual(['a.txt', 'b.txt']); const globCall = logSpy.mock.calls.find(call => call[0] === 'glob found'); expect(globCall).toBeTruthy(); @@ -306,8 +308,8 @@ describe('copyfiles', () => { writeFileSync('input/other/a.txt', 'a'); writeFileSync('input/other/b.txt', 'b'); writeFileSync('input/other/c.js', 'c'); - copyfiles(['input/**/*.txt', 'output'], { up: 2, verbose: true }, (err) => { - readdir('output', (err, files) => { + copyfiles(['input/**/*.txt', 'output'], { up: 2, verbose: true }, () => { + readdir('output', (_err, files) => { expect(files).toEqual(['a.txt', 'b.txt']); expect(logSpy).toHaveBeenCalledWith('glob found', ['input/other/a.txt', 'input/other/b.txt']); expect(logSpy).toHaveBeenCalledWith('copy:', { from: 'input/other/a.txt', to: 'output/a.txt' }); @@ -322,10 +324,9 @@ describe('copyfiles', () => { writeFileSync('input/.env.production', 'SOME=VALUE'); copyfiles(['input/.env.production', 'output/.env'], {}, (err) => { expect(err).toBeUndefined(); - readdir('output', (err, files) => { + readdir('output', (_err, files) => { expect(files).toContain('.env'); // Check file contents - const { readFileSync } = require('node:fs'); const content = readFileSync('output/.env', 'utf8'); expect(content).toBe('SOME=VALUE'); done(); @@ -337,14 +338,281 @@ describe('copyfiles', () => { writeFileSync('input/original.txt', 'HELLO WORLD'); copyfiles(['input/original.txt', 'output/renamed.txt'], {}, (err) => { expect(err).toBeUndefined(); - readdir('output', (err, files) => { + readdir('output', (_err, files) => { expect(files).toContain('renamed.txt'); // Check file contents - const { readFileSync } = require('node:fs'); const content = readFileSync('output/renamed.txt', 'utf8'); expect(content).toBe('HELLO WORLD'); done(); }); }); })); + + test('copies and renames files, using --flat option, from subfolders using wildcard in destination with .scss extension', () => new Promise((done: any) => { + // Setup: create input files in subfolders + createDir('input/sub1'); + createDir('input/sub2/deep1'); + writeFileSync('input/root.css', '.root { color: black }'); + writeFileSync('input/sub1/input1.css', 'h1 { color: red }'); + writeFileSync('input/sub2/input2.css', 'h2 { color: blue }'); + writeFileSync('input/sub2/input3.css', 'h3 { color: green }'); + writeFileSync('input/sub2/deep1/d1.css', '.d1 { color: yellow }'); + + copyfiles(['input/**/*.css', 'output/*.scss'], { flat: true }, (err) => { + expect(err).toBeUndefined(); + const files = readdirSync('output'); + expect(files).toEqual(expect.arrayContaining([ + 'root.scss', 'input1.scss', 'input2.scss', 'input3.scss', 'd1.scss' + ])); + expect(readFileSync('output/root.scss', 'utf8')).toBe('.root { color: black }'); + expect(readFileSync('output/input1.scss', 'utf8')).toBe('h1 { color: red }'); + expect(readFileSync('output/input2.scss', 'utf8')).toBe('h2 { color: blue }'); + expect(readFileSync('output/input3.scss', 'utf8')).toBe('h3 { color: green }'); + expect(readFileSync('output/d1.scss', 'utf8')).toBe('.d1 { color: yellow }'); + done(); + }); + })); + + test('copies and renames files, using --up option, from subfolders using wildcard in destination with .scss extension', () => new Promise((done: any) => { + // Setup: create input files in subfolders + createDir('input/sub1'); + createDir('input/sub2/deep1'); + writeFileSync('input/root.css', '.root { color: black }'); + writeFileSync('input/sub1/input1.css', 'h1 { color: red }'); + writeFileSync('input/sub2/input2.css', 'h2 { color: blue }'); + writeFileSync('input/sub2/input3.css', 'h3 { color: green }'); + writeFileSync('input/sub2/deep1/d1.css', '.d1 { color: yellow }'); + + copyfiles(['input/**/*.css', 'output/*.scss'], {}, (err) => { + expect(err).toBeUndefined(); + expect(readFileSync('output/input/root.scss', 'utf8')).toBe('.root { color: black }'); + expect(readFileSync('output/input/sub1/input1.scss', 'utf8')).toBe('h1 { color: red }'); + expect(readFileSync('output/input/sub2/input2.scss', 'utf8')).toBe('h2 { color: blue }'); + expect(readFileSync('output/input/sub2/input3.scss', 'utf8')).toBe('h3 { color: green }'); + expect(readFileSync('output/input/sub2/deep1/d1.scss', 'utf8')).toBe('.d1 { color: yellow }'); + done(); + }); + })); + + test('copies and renames files, using --up:1 option, from subfolders using wildcard in destination with .scss extension', () => new Promise((done: any) => { + // Setup: create input files in subfolders + createDir('input/deep1'); + writeFileSync('input/input1.css', 'h1 { color: red }'); + writeFileSync('input/input2.css', 'h2 { color: blue }'); + writeFileSync('input/input3.css', 'h3 { color: green }'); + writeFileSync('input/deep1/d1.css', '.d1 { color: yellow }'); + + copyfiles(['input/**/*.css', 'output/*.scss'], { up: true }, (err) => { + expect(err).toBeUndefined(); + expect(readFileSync('output/input1.scss', 'utf8')).toBe('h1 { color: red }'); + expect(readFileSync('output/input2.scss', 'utf8')).toBe('h2 { color: blue }'); + expect(readFileSync('output/input3.scss', 'utf8')).toBe('h3 { color: green }'); + expect(readFileSync('output/d1.scss', 'utf8')).toBe('.d1 { color: yellow }'); + done(); + }); + })); + + test('copies and renames files, using --up:1 option, from subfolders using wildcard in destination with .scss extension', () => new Promise((done: any) => { + // Setup: create input files in subfolders + createDir('input/sub1'); + createDir('input/sub2'); + createDir('input/sub2/deep1'); + writeFileSync('input/root.css', '.root { color: black }'); + writeFileSync('input/sub1/input1.css', 'h1 { color: red }'); + writeFileSync('input/sub2/input2.css', 'h2 { color: blue }'); + writeFileSync('input/sub2/input3.css', 'h3 { color: green }'); + writeFileSync('input/sub2/deep1/d1.css', '.d1 { color: yellow }'); + + copyfiles(['input/**/*.css', 'output/*.scss'], { up: 1 }, (err) => { + expect(err).toBeUndefined(); + expect(readFileSync('output/root.scss', 'utf8')).toBe('.root { color: black }'); + expect(readFileSync('output/sub1/input1.scss', 'utf8')).toBe('h1 { color: red }'); + expect(readFileSync('output/sub2/input2.scss', 'utf8')).toBe('h2 { color: blue }'); + expect(readFileSync('output/sub2/input3.scss', 'utf8')).toBe('h3 { color: green }'); + expect(readFileSync('output/sub2/deep1/d1.scss', 'utf8')).toBe('.d1 { color: yellow }'); + done(); + }); + })); + + test('copies and renames files, using --flat option and rename callback, from subfolders with .scss extension', () => new Promise((done: any) => { + // Setup: create input files in subfolders + createDir('input/sub1'); + createDir('input/sub2/deep1'); + writeFileSync('input/root.css', '.root { color: black }'); + writeFileSync('input/sub1/input1.css', 'h1 { color: red }'); + writeFileSync('input/sub2/input2.css', 'h2 { color: blue }'); + writeFileSync('input/sub2/input3.css', 'h3 { color: green }'); + writeFileSync('input/sub2/deep1/d1.css', '.d1 { color: yellow }'); + + copyfiles(['input/**/*.css', 'output'], { + flat: true, + rename: (_src, dest) => dest.replace(/\.css$/, '.scss') + }, (err) => { + expect(err).toBeUndefined(); + const files = readdirSync('output'); + expect(files).toEqual(expect.arrayContaining([ + 'root.scss', 'input1.scss', 'input2.scss', 'input3.scss', 'd1.scss' + ])); + expect(readFileSync('output/root.scss', 'utf8')).toBe('.root { color: black }'); + expect(readFileSync('output/input1.scss', 'utf8')).toBe('h1 { color: red }'); + expect(readFileSync('output/input2.scss', 'utf8')).toBe('h2 { color: blue }'); + expect(readFileSync('output/input3.scss', 'utf8')).toBe('h3 { color: green }'); + expect(readFileSync('output/d1.scss', 'utf8')).toBe('.d1 { color: yellow }'); + done(); + }); + })); + + test('copies and renames files, using --up:1 option and rename callback, from subfolders but keeps .css extension', () => new Promise((done: any) => { + // Setup: create input files in subfolders + createDir('input/sub1'); + createDir('input/sub2/deep1'); + writeFileSync('input/root.css', '.root { color: black }'); + writeFileSync('input/sub1/input1.css', 'h1 { color: red }'); + writeFileSync('input/sub2/input2.css', 'h2 { color: blue }'); + writeFileSync('input/sub2/input3.css', 'h3 { color: green }'); + writeFileSync('input/sub2/deep1/d1.css', '.d1 { color: yellow }'); + + copyfiles(['input/**/*.css', 'output'], { + up: 1, + rename: (_src, dest) => dest.replace(/([^/\\]+)\.css$/, 'renamed-$1.css') + }, (err) => { + expect(err).toBeUndefined(); + expect(readFileSync('output/renamed-root.css', 'utf8')).toBe('.root { color: black }'); + expect(readFileSync('output/sub1/renamed-input1.css', 'utf8')).toBe('h1 { color: red }'); + expect(readFileSync('output/sub2/renamed-input2.css', 'utf8')).toBe('h2 { color: blue }'); + expect(readFileSync('output/sub2/renamed-input3.css', 'utf8')).toBe('h3 { color: green }'); + expect(readFileSync('output/sub2/deep1/renamed-d1.css', 'utf8')).toBe('.d1 { color: yellow }'); + done(); + }); + })); + + test('copies and renames files using both destination glob and rename callback', () => new Promise((done: any) => { + createDir('input/sub'); + writeFileSync('input/foo.css', 'foo'); + writeFileSync('input/sub/bar.css', 'bar'); + copyfiles(['input/**/*.css', 'output/*.scss'], { + rename: (_src, dest) => dest.replace(/foo\.scss$/, 'baz.scss') + }, (err) => { + expect(err).toBeUndefined(); + expect(readFileSync('output/input/baz.scss', 'utf8')).toBe('foo'); + expect(readFileSync('output/input/sub/bar.scss', 'utf8')).toBe('bar'); + done(); + }); + })); + + test('copies and renames files, moving them to a subdirectory via rename callback', () => new Promise((done: any) => { + writeFileSync('input/a.txt', 'a'); + writeFileSync('input/b.txt', 'b'); + copyfiles(['input/*.txt', 'output'], { + rename: (_src, dest) => dest.replace('output', 'output/renamed') + }, (err) => { + expect(err).toBeUndefined(); + expect(readFileSync('output/renamed/input/a.txt', 'utf8')).toBe('a'); + expect(readFileSync('output/renamed/input/b.txt', 'utf8')).toBe('b'); + done(); + }); + })); + + test('copies files with rename callback that returns the same path', () => new Promise((done: any) => { + writeFileSync('input/a.txt', 'a'); + copyfiles(['input/a.txt', 'output'], { + rename: (_src, dest) => dest + }, (err) => { + expect(err).toBeUndefined(); + expect(readFileSync('output/input/a.txt', 'utf8')).toBe('a'); + done(); + }); + })); + + test('copies files and strips extension via rename callback', () => new Promise((done: any) => { + writeFileSync('input/a.txt', 'a'); + copyfiles(['input/a.txt', 'output'], { + rename: (_src, dest) => dest.replace(/\.txt$/, '') + }, (err) => { + expect(err).toBeUndefined(); + expect(readFileSync('output/input/a', 'utf8')).toBe('a'); + done(); + }); + })); + + test('calls callback with error if rename callback throws', () => new Promise((done: any) => { + writeFileSync('input/a.txt', 'a'); + copyfiles(['input/a.txt', 'output'], { + rename: () => { throw new Error('rename failed'); } + }, (err) => { + expect(err).toBeInstanceOf(Error); + expect(err?.message).toBe('rename failed'); + done(); + }); + })); + + test('copies and renames files, using --up:true and destination glob, from nested folders', () => new Promise((done: any) => { + createDir('input/level1/level2'); + writeFileSync('input/level1/level2/a.css', 'a'); + copyfiles(['input/**/*.css', 'output/*.scss'], { up: true }, (err) => { + expect(err).toBeUndefined(); + expect(readFileSync('output/a.scss', 'utf8')).toBe('a'); + done(); + }); + })); + + test('destination glob with source file with no extension', () => new Promise((done: any) => { + writeFileSync('input/file', 'abc'); + copyfiles(['input/file', 'output/*.txt'], {}, (err) => { + expect(err).toBeUndefined(); + expect(readFileSync('output/input/file.txt', 'utf8')).toBe('abc'); + done(); + }); + })); + + test('calls callback with error if rename callback throws (glob)', () => new Promise((done: any) => { + writeFileSync('input/a.txt', 'a'); + copyfiles(['input/a.txt', 'output/*.txt'], { + rename: () => { throw new Error('rename failed glob'); } + }, (err) => { + expect(err).toBeInstanceOf(Error); + expect(err?.message).toBe('rename failed glob'); + done(); + }); + })); + + test('destination glob with source file and destination with no extension', () => new Promise((done: any) => { + writeFileSync('input/file', 'abc'); + copyfiles(['input/file', 'output/*'], {}, (err) => { + expect(err).toBeUndefined(); + expect(readFileSync('output/input/file', 'utf8')).toBe('abc'); + done(); + }); + })); + + test('throws when destination is missing (no callback)', () => { + expect(() => copyfiles(['input/a.txt'], {})).toThrow( + 'Please make sure to provide both and , i.e.: "copyfiles "' + ); + }); + + test('throws when nothing is copied and error option is set (no callback)', () => { + createDir('output'); + expect(() => { + copyfiles(['input/doesnotexist.txt', 'output'], { error: true }); + }).toThrow('nothing copied'); + }); + + test('throws with rename glob and up 2', () => new Promise((done: any) => { + // Setup: create input files in subfolders + createDir('input/sub1'); + createDir('input/sub2/deep1'); + writeFileSync('input/root.css', '.root { color: black }'); + writeFileSync('input/sub1/input1.css', 'h1 { color: red }'); + writeFileSync('input/sub2/input2.css', 'h2 { color: blue }'); + writeFileSync('input/sub2/input3.css', 'h3 { color: green }'); + writeFileSync('input/sub2/deep1/d1.css', '.d1 { color: yellow }'); + + copyfiles(['input/**/*.css', 'output/*.scss'], { up: 2 }, (err) => { + if (err) { + expect(err?.message).toBe(`Can't go up 2 levels from input (1 levels).`); + done(); + } + }); + })); }); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index f3ec1b6..e2d4a42 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ -import { createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs'; -import path, { basename, dirname, join, normalize, posix, sep } from 'node:path'; +import { createReadStream, createWriteStream, existsSync, mkdirSync, statSync } from 'node:fs'; +import path, { basename, dirname, extname, join, normalize, posix, sep } from 'node:path'; import untildify from 'untildify'; import { type GlobOptions, globSync } from 'tinyglobby'; @@ -26,6 +26,69 @@ function throwOrCallback(err: Error, cb?: (e?: Error) => void) { } } +function callRenameWhenDefined(inFile: string, dest: string, options: CopyFileOptions): string { + if (typeof options.rename === 'function') { + return options.rename(inFile, dest); + } + return dest; +} + +/** + * Calculate the destination path for a given input file and options. + */ +function getDestinationPath( + inFile: string, + outDir: string, + options: CopyFileOptions, + isSingleFileRename = false +): string { + const fileDir = dirname(inFile); + const fileName = basename(inFile); + const srcExt = extname(fileName); + const srcBase = fileName && srcExt ? fileName.slice(0, -srcExt.length) : fileName; + const upCount = options.up || 0; + + // 1. Single file rename (no glob, dest is not a directory, no *) + if (isSingleFileRename && !outDir.includes('*')) { + let dest = outDir; + return callRenameWhenDefined(inFile, dest, options); + } + + // 2. Wildcard pattern in destination + if (outDir.includes('*')) { + // Replace * with base name (without extension) + const destFileName = outDir.replace('*', srcBase); + // If the pattern after replacement has no extension, add the extension from the pattern or the source + let finalDestFileName = destFileName; + if (!extname(destFileName)) { + finalDestFileName += extname(outDir) || srcExt; + } + + const baseOutDir = outDir.replace(/[*][^\\\/]*$/, ''); + let dest: string; + if (options.flat || upCount === true) { + dest = join(baseOutDir, basename(finalDestFileName)); + } else if (upCount) { + const upPath = dealWith(fileDir, upCount); + dest = join(baseOutDir, upPath, basename(finalDestFileName)); + } else { + dest = join(baseOutDir, fileDir, basename(finalDestFileName)); + } + return callRenameWhenDefined(inFile, dest, options); + } + + // 3. Flat or up logic (no wildcard) + let baseDir: string; + if (options.flat || upCount === true) { + baseDir = outDir; + } else { + baseDir = join(outDir, dealWith(fileDir, upCount)); + } + let dest = join(baseDir, fileName); + + return callRenameWhenDefined(inFile, dest, options); +} + /** * Copy the files per a glob pattern, the first item(s) can be a 1 or more files to copy * while the last item in the array is the output outDirectory directory @@ -61,13 +124,18 @@ export function copyfiles(paths: string[], options: CopyFileOptions, callback?: let outPath = paths.pop() as string; outPath = outPath.startsWith('~') ? untildify(outPath) : outPath; - // Special case: single file to file (rename) + // Detect single file rename (no glob, dest is not a directory, no *) const isSingleFile = sources.length === 1 && !sources[0].includes('*'); let isDestFile = false; if (isSingleFile) { try { - const stat = existsSync(outPath) ? require('node:fs').statSync(outPath) : null; - isDestFile = !stat || !stat.isDirectory(); + // If the output path doesn't exist, treat as file if it has an extension or ends with a dotfile + if (!existsSync(outPath)) { + isDestFile = !!extname(outPath) || basename(outPath).startsWith('.'); + } else { + const stat = statSync(outPath); + isDestFile = !stat.isDirectory(); + } /* v8 ignore next 3 */ } catch { isDestFile = true; @@ -76,7 +144,7 @@ export function copyfiles(paths: string[], options: CopyFileOptions, callback?: if (!isDestFile) { // create destination directory if not exists - createDir(outPath); + createDir(dirname(outPath)); } let globOptions: GlobOptions = {}; @@ -136,7 +204,7 @@ export function copyfiles(paths: string[], options: CopyFileOptions, callback?: if (typeof cb === 'function') cb(); } }, - isSingleFile && isDestFile // pass as "rename" mode + isSingleFile && isDestFile // pass as single rename mode ); }); } @@ -153,31 +221,19 @@ function copyFileStream( outDir: string, options: CopyFileOptions, cb: (e?: Error) => void, - renameMode = false + isSingleFileRename = false ) { - const fileDir = dirname(inFile); - const fileName = basename(inFile); outDir = outDir.startsWith('~') ? untildify(outDir) : outDir; - let dest: string; - if (renameMode) { - dest = outDir; - createDir(path.dirname(dest)); - } else if (options.flat || options.up === true) { - dest = join(outDir, fileName); - } else { - const upCount = options.up || 0; - let destDir: string; - try { - destDir = join(outDir, dealWith(fileDir, upCount)); - } catch (err) { - cb(err as Error); - return; - } - createDir(destDir); - dest = join(destDir, fileName); + try { + dest = getDestinationPath(inFile, outDir, options, isSingleFileRename); + } catch (err) { + cb(err as Error); + return; } + createDir(dirname(dest)); + if (options.verbose) { console.log('copy:', { from: convertToPosix(inFile), to: convertToPosix(dest) }); } @@ -207,18 +263,18 @@ function copyFileStream( function convertToPosix(pathStr: string) { return pathStr.replaceAll(sep, posix.sep); } +} - function depth(str: string) { - return normalize(str).split(sep).length; - } +function depth(str: string) { + return normalize(str).split(sep).length; +} - function dealWith(inPath: string, up: number) { - if (!up) { - return inPath; - } - if (depth(inPath) < up) { - throw new Error(`Can't go up ${up} levels from ${inPath} (${depth(inPath)} levels).`); - } - return path.join.apply(path, normalize(inPath).split(sep).slice(up)); +function dealWith(inPath: string, up: number) { + if (!up) { + return inPath; + } + if (depth(inPath) < up) { + throw new Error(`Can't go up ${up} levels from ${inPath} (${depth(inPath)} levels).`); } + return join.apply(path, normalize(inPath).split(sep).slice(up)); } \ No newline at end of file diff --git a/src/interfaces.ts b/src/interfaces.ts index 62217af..50b7d7f 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,28 +1,34 @@ export interface CopyFileOptions { - /** include files & directories begining with a dot (.) */ + /** Include files & directories beginning with a dot (.) */ all?: boolean; - /** throw error if nothing is copied */ + /** Throw error if nothing is copied */ error?: boolean; - /** pattern or glob to exclude (may be passed multiple times) */ + /** Pattern or glob to exclude files (may be passed multiple times in the CLI) */ exclude?: string | string[]; - /** flatten the output */ + /** Flatten the output */ flat?: boolean; - /** follow symbolink links */ + /** Follow symbolic link links */ follow?: boolean; - /** show statistics after execution (execution time + file count) */ + /** Show statistics after execution (execution time + file count) */ stat?: boolean; - /** slice a path off the bottom of the paths */ + /** + * Slice a path off the bottom of the paths. + * Note: when is assigned with `up: true`, it is equivalent to `flat: true` + */ up?: boolean | number; - /** print more information to console */ + /** Print files being copied to the console */ verbose?: boolean; - /** callback to run when the execution finished or an error occured */ - callback?: (e?: Error) => void + /** Callback to run when the execution finished or an error occured */ + callback?: (e?: Error) => void; + + /** Callback to transform the destination filename(s) */ + rename?: (src: string, dest: string) => string; } \ No newline at end of file