diff --git a/src/generators/web/generate.mjs b/src/generators/web/generate.mjs index 7ec803ef..e5b19fb8 100644 --- a/src/generators/web/generate.mjs +++ b/src/generators/web/generate.mjs @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { copyStaticAssets } from './utils/copying.mjs'; import { processJSXEntries } from './utils/processing.mjs'; import getConfig from '../../utils/configuration/index.mjs'; import { writeFile } from '../../utils/file.mjs'; @@ -39,6 +40,8 @@ export async function generate(input) { } await writeFile(join(config.output, 'styles.css'), css, 'utf-8'); + + await copyStaticAssets(config); } return results.map(({ html }) => ({ html: html.toString(), css })); diff --git a/src/generators/web/utils/__tests__/copying.test.mjs b/src/generators/web/utils/__tests__/copying.test.mjs new file mode 100644 index 00000000..385ab714 --- /dev/null +++ b/src/generators/web/utils/__tests__/copying.test.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { describe, it, mock, beforeEach } from 'node:test'; + +const mockCp = mock.fn(() => Promise.resolve()); +mock.module('node:fs/promises', { + namedExports: { cp: mockCp }, +}); + +const mockLogError = mock.fn(); +mock.module('../../../../logger/index.mjs', { + defaultExport: { error: mockLogError }, +}); + +const { copyStaticAssets } = await import('../copying.mjs'); + +describe('copyStaticAssets', () => { + beforeEach(() => { + mockCp.mock.resetCalls(); + mockLogError.mock.resetCalls(); + mockCp.mock.mockImplementation(() => Promise.resolve()); + }); + + it('does nothing if config.pathsToCopy is not an array', async () => { + await copyStaticAssets({ pathsToCopy: undefined }); + assert.strictEqual(mockCp.mock.callCount(), 0); + }); + + it('ignores falsy items in pathsToCopy array', async () => { + const config = { + output: '/out', + pathsToCopy: [null, undefined, false, ''], + }; + await copyStaticAssets(config); + assert.strictEqual(mockCp.mock.callCount(), 0); + }); + + it('copies simple string paths correctly to the output directory', async () => { + const config = { + output: '/out', + pathsToCopy: ['src/assets', 'docs/images'], + }; + + await copyStaticAssets(config); + + assert.strictEqual(mockCp.mock.callCount(), 2); + + assert.deepStrictEqual(mockCp.mock.calls[0].arguments, [ + 'src/assets', + join('/out', 'assets'), + { recursive: true, force: true }, + ]); + + assert.deepStrictEqual(mockCp.mock.calls[1].arguments, [ + 'docs/images', + join('/out', 'images'), + { recursive: true, force: true }, + ]); + }); + + it('copies object mappings correctly and strips leading slashes from dest', async () => { + const config = { + output: '/out', + pathsToCopy: [ + { + 'src/custom': '/dest-folder/custom', // Leading slash should be stripped + 'src/another': 'another-folder', + }, + ], + }; + + await copyStaticAssets(config); + + assert.strictEqual(mockCp.mock.callCount(), 2); + + assert.deepStrictEqual(mockCp.mock.calls[0].arguments, [ + 'src/custom', + join('/out', 'dest-folder/custom'), + { recursive: true, force: true }, + ]); + + assert.deepStrictEqual(mockCp.mock.calls[1].arguments, [ + 'src/another', + join('/out', 'another-folder'), + { recursive: true, force: true }, + ]); + }); + + it('ignores ENOENT errors silently', async () => { + // Simulate an ENOENT error when trying to copy + mockCp.mock.mockImplementationOnce(() => { + const err = new Error('File not found'); + err.code = 'ENOENT'; + throw err; + }); + + await copyStaticAssets({ + output: '/out', + pathsToCopy: ['missing-file'], + }); + + assert.strictEqual(mockCp.mock.callCount(), 1); + assert.strictEqual(mockLogError.mock.callCount(), 0); + }); + + it('logs errors that are not ENOENT using the logger', async () => { + // Simulate a generic/permission error + mockCp.mock.mockImplementationOnce(() => { + throw new Error('Permission denied'); + }); + + await copyStaticAssets({ + output: '/out', + pathsToCopy: ['protected-file'], + }); + + assert.strictEqual(mockCp.mock.callCount(), 1); + assert.strictEqual(mockLogError.mock.callCount(), 1); + + const logMessage = mockLogError.mock.calls[0].arguments[0]; + assert.match( + logMessage, + /\[web-generator\] Failed to copy asset from protected-file to \/out\/protected-file: Permission denied/ + ); + }); +}); diff --git a/src/generators/web/utils/copying.mjs b/src/generators/web/utils/copying.mjs new file mode 100644 index 00000000..20bd5d12 --- /dev/null +++ b/src/generators/web/utils/copying.mjs @@ -0,0 +1,38 @@ +import { cp } from 'node:fs/promises'; +import { join, basename } from 'node:path'; + +import logger from '../../../logger/index.mjs'; + +/** + * Copies static directories/files defined in `pathsToCopy` to the output directory. + * @param {import('../types').Configuration} config + */ +export async function copyStaticAssets(config) { + if (Array.isArray(config.pathsToCopy)) { + for (const item of config.pathsToCopy) { + if (!item) { + continue; + } + + const copyTasks = + typeof item === 'string' + ? [{ src: item, dest: join(config.output, basename(item)) }] + : Object.entries(item).map(([src, dest]) => ({ + src, + dest: join(config.output, dest.replace(/^[/\\]+/, '')), + })); + + for (const { src, dest } of copyTasks) { + try { + await cp(src, dest, { recursive: true, force: true }); + } catch (err) { + if (err.code !== 'ENOENT') { + logger.error( + `[web-generator] Failed to copy asset from ${src} to ${dest}: ${err.message}` + ); + } + } + } + } + } +} diff --git a/src/utils/configuration/index.mjs b/src/utils/configuration/index.mjs index 8323fe8d..b20a634a 100644 --- a/src/utils/configuration/index.mjs +++ b/src/utils/configuration/index.mjs @@ -32,6 +32,7 @@ export const getDefaultConfig = lazy(() => repository: 'nodejs/node', ref: 'HEAD', }), + pathsToCopy: ['assets', 'public', 'static'], }, // The number of wasm memory instances is severely limited on