From 56ae71a368f356b1da6ec613fb20596a59f8674e Mon Sep 17 00:00:00 2001 From: Mohamed Shams El-Deen Date: Thu, 11 Jun 2026 19:13:02 +0300 Subject: [PATCH 1/6] feat(web): support copying static directories via pathsToCopy --- src/generators/web/generate.mjs | 37 +++++++++++++++++++++++++++++-- src/utils/configuration/index.mjs | 1 + 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/generators/web/generate.mjs b/src/generators/web/generate.mjs index 7ec803ef..6f434b2d 100644 --- a/src/generators/web/generate.mjs +++ b/src/generators/web/generate.mjs @@ -1,7 +1,8 @@ 'use strict'; -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { constants } from 'node:fs'; +import { readFile, stat, cp, copyFile, mkdir } from 'node:fs/promises'; +import { join, basename, dirname } from 'node:path'; import { processJSXEntries } from './utils/processing.mjs'; import getConfig from '../../utils/configuration/index.mjs'; @@ -39,6 +40,38 @@ export async function generate(input) { } await writeFile(join(config.output, 'styles.css'), css, 'utf-8'); + + if (Array.isArray(config.pathsToCopy)) { + for (const item of config.pathsToCopy) { + 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), + })); + + for (const { src, dest } of copyTasks) { + try { + const fileStats = await stat(src); + + if (fileStats.isDirectory()) { + await cp(src, dest, { recursive: true, force: true }); + } else { + await mkdir(dirname(dest), { recursive: true }); + await copyFile(src, dest, constants.COPYFILE_FICLONE); + } + } catch (err) { + if (err.code !== 'ENOENT') { + console.error( + `Failed to copy asset from ${src} to ${dest}:`, + err + ); + } + } + } + } + } } return results.map(({ html }) => ({ html: html.toString(), css })); 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 From 89bd3ee02caae4e0157a2530ce81942b9d0aa3a4 Mon Sep 17 00:00:00 2001 From: Mohamed Shams El-Deen Date: Thu, 11 Jun 2026 19:39:10 +0300 Subject: [PATCH 2/6] fixup! --- src/generators/web/generate.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/generators/web/generate.mjs b/src/generators/web/generate.mjs index 6f434b2d..7b36e21a 100644 --- a/src/generators/web/generate.mjs +++ b/src/generators/web/generate.mjs @@ -43,12 +43,15 @@ export async function generate(input) { 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), + dest: join(config.output, dest.replace(/^[/\\]+/, '')), })); for (const { src, dest } of copyTasks) { From fda965585c1b5eacc9e8f3e533997d3cdd2d0470 Mon Sep 17 00:00:00 2001 From: Mohamed Shams El-Deen Date: Fri, 12 Jun 2026 05:52:07 +0300 Subject: [PATCH 3/6] refactor(web): extract copying logic and use built-in logger --- src/generators/web/generate.mjs | 41 +++---------------------- src/generators/web/utils/copying.mjs | 46 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 37 deletions(-) create mode 100644 src/generators/web/utils/copying.mjs diff --git a/src/generators/web/generate.mjs b/src/generators/web/generate.mjs index 7b36e21a..e5b19fb8 100644 --- a/src/generators/web/generate.mjs +++ b/src/generators/web/generate.mjs @@ -1,9 +1,9 @@ 'use strict'; -import { constants } from 'node:fs'; -import { readFile, stat, cp, copyFile, mkdir } from 'node:fs/promises'; -import { join, basename, dirname } from 'node:path'; +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'; @@ -41,40 +41,7 @@ export async function generate(input) { await writeFile(join(config.output, 'styles.css'), css, 'utf-8'); - 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 { - const fileStats = await stat(src); - - if (fileStats.isDirectory()) { - await cp(src, dest, { recursive: true, force: true }); - } else { - await mkdir(dirname(dest), { recursive: true }); - await copyFile(src, dest, constants.COPYFILE_FICLONE); - } - } catch (err) { - if (err.code !== 'ENOENT') { - console.error( - `Failed to copy asset from ${src} to ${dest}:`, - err - ); - } - } - } - } - } + await copyStaticAssets(config); } return results.map(({ html }) => ({ html: html.toString(), css })); diff --git a/src/generators/web/utils/copying.mjs b/src/generators/web/utils/copying.mjs new file mode 100644 index 00000000..182b19b6 --- /dev/null +++ b/src/generators/web/utils/copying.mjs @@ -0,0 +1,46 @@ +import { constants } from 'node:fs'; +import { stat, cp, mkdir, copyFile } from 'node:fs/promises'; +import { join, basename, dirname } 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 { + const fileStats = await stat(src); + + if (fileStats.isDirectory()) { + await cp(src, dest, { recursive: true, force: true }); + } else { + await mkdir(dirname(dest), { recursive: true }); + await copyFile(src, dest, constants.COPYFILE_FICLONE); + } + } catch (err) { + if (err.code !== 'ENOENT') { + logger.error( + `[web-generator] Failed to copy asset from ${src} to ${dest}: ${err.message}` + ); + } + } + } + } + } +} From 99ac1723ec16d962ddd6908cc135e3f736c458af Mon Sep 17 00:00:00 2001 From: Mohamed Shams El-Deen Date: Fri, 12 Jun 2026 05:59:38 +0300 Subject: [PATCH 4/6] fixup! --- src/generators/web/utils/copying.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/generators/web/utils/copying.mjs b/src/generators/web/utils/copying.mjs index 182b19b6..e90cb7c9 100644 --- a/src/generators/web/utils/copying.mjs +++ b/src/generators/web/utils/copying.mjs @@ -2,7 +2,7 @@ import { constants } from 'node:fs'; import { stat, cp, mkdir, copyFile } from 'node:fs/promises'; import { join, basename, dirname } from 'node:path'; -import logger from '../../logger/index.mjs'; +import logger from '../../../logger/index.mjs'; /** * Copies static directories/files defined in `pathsToCopy` to the output directory. From f2bd3fc5be12362613614e3537f5779d11ee6fa2 Mon Sep 17 00:00:00 2001 From: Mohamed Shams El-Deen Date: Mon, 15 Jun 2026 08:38:27 +0300 Subject: [PATCH 5/6] fixup! --- src/generators/web/utils/copying.mjs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/generators/web/utils/copying.mjs b/src/generators/web/utils/copying.mjs index e90cb7c9..20bd5d12 100644 --- a/src/generators/web/utils/copying.mjs +++ b/src/generators/web/utils/copying.mjs @@ -1,6 +1,5 @@ -import { constants } from 'node:fs'; -import { stat, cp, mkdir, copyFile } from 'node:fs/promises'; -import { join, basename, dirname } from 'node:path'; +import { cp } from 'node:fs/promises'; +import { join, basename } from 'node:path'; import logger from '../../../logger/index.mjs'; @@ -25,14 +24,7 @@ export async function copyStaticAssets(config) { for (const { src, dest } of copyTasks) { try { - const fileStats = await stat(src); - - if (fileStats.isDirectory()) { - await cp(src, dest, { recursive: true, force: true }); - } else { - await mkdir(dirname(dest), { recursive: true }); - await copyFile(src, dest, constants.COPYFILE_FICLONE); - } + await cp(src, dest, { recursive: true, force: true }); } catch (err) { if (err.code !== 'ENOENT') { logger.error( From 7369ac534fc18d6edcb22943bdfa85864fbbbe8a Mon Sep 17 00:00:00 2001 From: Mohamed Shams El-Deen Date: Mon, 15 Jun 2026 09:15:23 +0300 Subject: [PATCH 6/6] test(web): add unit tests for copyStaticAssets utility --- .../web/utils/__tests__/copying.test.mjs | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/generators/web/utils/__tests__/copying.test.mjs 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/ + ); + }); +});