Skip to content

Commit 3fbb9fb

Browse files
authored
feat: assets copying (#753)
* feat(web): support copying static directories via pathsToCopy * fixup! * refactor(web): extract copying logic and use built-in logger * fixup! * fixup! * test(web): add unit tests for copyStaticAssets utility
1 parent f78dea3 commit 3fbb9fb

4 files changed

Lines changed: 168 additions & 0 deletions

File tree

src/generators/web/generate.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { readFile } from 'node:fs/promises';
44
import { join } from 'node:path';
55

6+
import { copyStaticAssets } from './utils/copying.mjs';
67
import { processJSXEntries } from './utils/processing.mjs';
78
import getConfig from '../../utils/configuration/index.mjs';
89
import { writeFile } from '../../utils/file.mjs';
@@ -39,6 +40,8 @@ export async function generate(input) {
3940
}
4041

4142
await writeFile(join(config.output, 'styles.css'), css, 'utf-8');
43+
44+
await copyStaticAssets(config);
4245
}
4346

4447
return results.map(({ html }) => ({ html: html.toString(), css }));
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import assert from 'node:assert/strict';
2+
import { join } from 'node:path';
3+
import { describe, it, mock, beforeEach } from 'node:test';
4+
5+
const mockCp = mock.fn(() => Promise.resolve());
6+
mock.module('node:fs/promises', {
7+
namedExports: { cp: mockCp },
8+
});
9+
10+
const mockLogError = mock.fn();
11+
mock.module('../../../../logger/index.mjs', {
12+
defaultExport: { error: mockLogError },
13+
});
14+
15+
const { copyStaticAssets } = await import('../copying.mjs');
16+
17+
describe('copyStaticAssets', () => {
18+
beforeEach(() => {
19+
mockCp.mock.resetCalls();
20+
mockLogError.mock.resetCalls();
21+
mockCp.mock.mockImplementation(() => Promise.resolve());
22+
});
23+
24+
it('does nothing if config.pathsToCopy is not an array', async () => {
25+
await copyStaticAssets({ pathsToCopy: undefined });
26+
assert.strictEqual(mockCp.mock.callCount(), 0);
27+
});
28+
29+
it('ignores falsy items in pathsToCopy array', async () => {
30+
const config = {
31+
output: '/out',
32+
pathsToCopy: [null, undefined, false, ''],
33+
};
34+
await copyStaticAssets(config);
35+
assert.strictEqual(mockCp.mock.callCount(), 0);
36+
});
37+
38+
it('copies simple string paths correctly to the output directory', async () => {
39+
const config = {
40+
output: '/out',
41+
pathsToCopy: ['src/assets', 'docs/images'],
42+
};
43+
44+
await copyStaticAssets(config);
45+
46+
assert.strictEqual(mockCp.mock.callCount(), 2);
47+
48+
assert.deepStrictEqual(mockCp.mock.calls[0].arguments, [
49+
'src/assets',
50+
join('/out', 'assets'),
51+
{ recursive: true, force: true },
52+
]);
53+
54+
assert.deepStrictEqual(mockCp.mock.calls[1].arguments, [
55+
'docs/images',
56+
join('/out', 'images'),
57+
{ recursive: true, force: true },
58+
]);
59+
});
60+
61+
it('copies object mappings correctly and strips leading slashes from dest', async () => {
62+
const config = {
63+
output: '/out',
64+
pathsToCopy: [
65+
{
66+
'src/custom': '/dest-folder/custom', // Leading slash should be stripped
67+
'src/another': 'another-folder',
68+
},
69+
],
70+
};
71+
72+
await copyStaticAssets(config);
73+
74+
assert.strictEqual(mockCp.mock.callCount(), 2);
75+
76+
assert.deepStrictEqual(mockCp.mock.calls[0].arguments, [
77+
'src/custom',
78+
join('/out', 'dest-folder/custom'),
79+
{ recursive: true, force: true },
80+
]);
81+
82+
assert.deepStrictEqual(mockCp.mock.calls[1].arguments, [
83+
'src/another',
84+
join('/out', 'another-folder'),
85+
{ recursive: true, force: true },
86+
]);
87+
});
88+
89+
it('ignores ENOENT errors silently', async () => {
90+
// Simulate an ENOENT error when trying to copy
91+
mockCp.mock.mockImplementationOnce(() => {
92+
const err = new Error('File not found');
93+
err.code = 'ENOENT';
94+
throw err;
95+
});
96+
97+
await copyStaticAssets({
98+
output: '/out',
99+
pathsToCopy: ['missing-file'],
100+
});
101+
102+
assert.strictEqual(mockCp.mock.callCount(), 1);
103+
assert.strictEqual(mockLogError.mock.callCount(), 0);
104+
});
105+
106+
it('logs errors that are not ENOENT using the logger', async () => {
107+
// Simulate a generic/permission error
108+
mockCp.mock.mockImplementationOnce(() => {
109+
throw new Error('Permission denied');
110+
});
111+
112+
await copyStaticAssets({
113+
output: '/out',
114+
pathsToCopy: ['protected-file'],
115+
});
116+
117+
assert.strictEqual(mockCp.mock.callCount(), 1);
118+
assert.strictEqual(mockLogError.mock.callCount(), 1);
119+
120+
const logMessage = mockLogError.mock.calls[0].arguments[0];
121+
assert.match(
122+
logMessage,
123+
/\[web-generator\] Failed to copy asset from protected-file to \/out\/protected-file: Permission denied/
124+
);
125+
});
126+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { cp } from 'node:fs/promises';
2+
import { join, basename } from 'node:path';
3+
4+
import logger from '../../../logger/index.mjs';
5+
6+
/**
7+
* Copies static directories/files defined in `pathsToCopy` to the output directory.
8+
* @param {import('../types').Configuration} config
9+
*/
10+
export async function copyStaticAssets(config) {
11+
if (Array.isArray(config.pathsToCopy)) {
12+
for (const item of config.pathsToCopy) {
13+
if (!item) {
14+
continue;
15+
}
16+
17+
const copyTasks =
18+
typeof item === 'string'
19+
? [{ src: item, dest: join(config.output, basename(item)) }]
20+
: Object.entries(item).map(([src, dest]) => ({
21+
src,
22+
dest: join(config.output, dest.replace(/^[/\\]+/, '')),
23+
}));
24+
25+
for (const { src, dest } of copyTasks) {
26+
try {
27+
await cp(src, dest, { recursive: true, force: true });
28+
} catch (err) {
29+
if (err.code !== 'ENOENT') {
30+
logger.error(
31+
`[web-generator] Failed to copy asset from ${src} to ${dest}: ${err.message}`
32+
);
33+
}
34+
}
35+
}
36+
}
37+
}
38+
}

src/utils/configuration/index.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const getDefaultConfig = lazy(() =>
3232
repository: 'nodejs/node',
3333
ref: 'HEAD',
3434
}),
35+
pathsToCopy: ['assets', 'public', 'static'],
3536
},
3637

3738
// The number of wasm memory instances is severely limited on

0 commit comments

Comments
 (0)