From a9613c408c87285fe596fce7a7bd8f43715f1eee Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 18 Apr 2026 11:46:58 -0700 Subject: [PATCH 1/4] Allow --copy to accept individual files alongside directories getCopyDirs now stats each path to determine whether it is a file or a directory. Directories keep the existing behavior (glob appended with /**). Files are passed as-is to cpx2, which copies them directly into the destination root. Paths that cannot be stat'd (not yet created, permission errors) fall back to directory glob behavior to preserve backwards compatibility. Closes #236 --- index.js | 2 +- lib/build-copy/index.js | 22 +++++++++++++++++----- lib/build-copy/index.test.js | 4 ++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/index.js b/index.js index 3a062db..91a810f 100644 --- a/index.js +++ b/index.js @@ -247,7 +247,7 @@ export class DomStack { await this.#rebuildMaps(siteData) // ── Copy watchers & browser-sync ───────────────────────────────────── - const copyDirs = getCopyDirs(this.opts.copy) + const copyDirs = await getCopyDirs(this.opts.copy) this.#cpxWatchers = [ cpx.watch(getCopyGlob(this.#src), this.#dest, { ignore: this.opts.ignore }), diff --git a/lib/build-copy/index.js b/lib/build-copy/index.js index 5b81b9e..8f5cdaa 100644 --- a/lib/build-copy/index.js +++ b/lib/build-copy/index.js @@ -5,6 +5,7 @@ // @ts-expect-error import cpx from 'cpx2' import { join } from 'node:path' +import { stat } from 'node:fs/promises' const copy = cpx.copy /** @@ -15,11 +16,22 @@ const copy = cpx.copy /** * @param {string[]} copy - * @return {string[]} + * @return {Promise} */ -export function getCopyDirs (copy = []) { - const copyGlobs = copy?.map((dir) => join(dir, '**')) - return copyGlobs +export async function getCopyDirs (copy = []) { + const globs = await Promise.all(copy.map(async (entry) => { + try { + const stats = await stat(entry) + if (stats.isDirectory()) { + return join(entry, '**') + } + } catch { + // Path not accessible yet — treat as directory glob + return join(entry, '**') + } + return entry + })) + return globs } /** @@ -36,7 +48,7 @@ export async function buildCopy (_src, dest, _siteData, opts) { warnings: [], } - const copyDirs = getCopyDirs(opts?.copy) + const copyDirs = await getCopyDirs(opts?.copy) const copyTasks = copyDirs.map((copyDir) => { return copy(copyDir, dest) diff --git a/lib/build-copy/index.test.js b/lib/build-copy/index.test.js index 4651210..f8d45cf 100644 --- a/lib/build-copy/index.test.js +++ b/lib/build-copy/index.test.js @@ -3,8 +3,8 @@ import assert from 'node:assert' import { getCopyDirs } from './index.js' test.describe('build-copy', () => { - test('getCopyDirs returns correct src/dest pairs', async () => { - const copyDirs = getCopyDirs(['fixtures']) + test('getCopyDirs appends ** for non-existent paths', async () => { + const copyDirs = await getCopyDirs(['fixtures']) assert.deepStrictEqual(copyDirs, ['fixtures/**']) }) From da713b084a98505e6852cb1fad97000ec3bde26a Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 18 Apr 2026 12:00:20 -0700 Subject: [PATCH 2/4] Rename copyDirs to copyGlobs in buildCopy, add file path tests Renames the local variable in buildCopy to copyGlobs to clarify that the array now contains either directory globs or file paths, not just dirs. Adds two tests: one asserting existing files are passed through as-is, and one asserting existing directories still get /** appended. --- lib/build-copy/index.js | 10 +++++----- lib/build-copy/index.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/lib/build-copy/index.js b/lib/build-copy/index.js index 8f5cdaa..1fbb607 100644 --- a/lib/build-copy/index.js +++ b/lib/build-copy/index.js @@ -48,22 +48,22 @@ export async function buildCopy (_src, dest, _siteData, opts) { warnings: [], } - const copyDirs = await getCopyDirs(opts?.copy) + const copyGlobs = await getCopyDirs(opts?.copy) - const copyTasks = copyDirs.map((copyDir) => { - return copy(copyDir, dest) + const copyTasks = copyGlobs.map((copyGlob) => { + return copy(copyGlob, dest) }) const settled = await Promise.allSettled(copyTasks) for (const [index, result] of Object.entries(settled)) { // @ts-expect-error - const copyDir = copyDirs[index] + const copyGlob = copyGlobs[index] if (result.status === 'rejected') { const buildError = new Error('Error copying copy folders', { cause: result.reason }) results.errors.push(buildError) } else { - results.report[copyDir] = result.value + results.report[copyGlob] = result.value } } return results diff --git a/lib/build-copy/index.test.js b/lib/build-copy/index.test.js index f8d45cf..377e991 100644 --- a/lib/build-copy/index.test.js +++ b/lib/build-copy/index.test.js @@ -1,5 +1,8 @@ import { test } from 'node:test' import assert from 'node:assert' +import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import os from 'node:os' import { getCopyDirs } from './index.js' test.describe('build-copy', () => { @@ -8,4 +11,26 @@ test.describe('build-copy', () => { assert.deepStrictEqual(copyDirs, ['fixtures/**']) }) + + test('getCopyDirs returns file path as-is for existing files', async () => { + const dir = await mkdtemp(join(os.tmpdir(), 'domstack-copy-test-')) + const file = join(dir, 'sw.js') + try { + await writeFile(file, 'self.addEventListener("fetch", () => {})') + const result = await getCopyDirs([file]) + assert.deepStrictEqual(result, [file]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test('getCopyDirs appends ** for existing directories', async () => { + const dir = await mkdtemp(join(os.tmpdir(), 'domstack-copy-test-')) + try { + const result = await getCopyDirs([dir]) + assert.deepStrictEqual(result, [join(dir, '**')]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) }) From 6e66ea9104a1de36e50a3e4434f28f0736245c24 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 18 Apr 2026 12:52:45 -0700 Subject: [PATCH 3/4] Fix error message to say 'copy path' instead of 'copy folders' --- lib/build-copy/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/build-copy/index.js b/lib/build-copy/index.js index 1fbb607..c8c483d 100644 --- a/lib/build-copy/index.js +++ b/lib/build-copy/index.js @@ -60,7 +60,7 @@ export async function buildCopy (_src, dest, _siteData, opts) { // @ts-expect-error const copyGlob = copyGlobs[index] if (result.status === 'rejected') { - const buildError = new Error('Error copying copy folders', { cause: result.reason }) + const buildError = new Error(`Error copying copy path: ${copyGlob}`, { cause: result.reason }) results.errors.push(buildError) } else { results.report[copyGlob] = result.value From adedcf3914b33a5daf2b615c79f6f24f80392957 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 18 Apr 2026 19:49:52 -0700 Subject: [PATCH 4/4] Fix ambiguous test path, update bin help text and README to mention file support Co-Authored-By: Claude Sonnet 4.6 --- README.md | 6 +++--- bin.js | 2 +- lib/build-copy/index.test.js | 11 ++++++++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 008f8fb..4c552d7 100644 --- a/README.md +++ b/README.md @@ -710,11 +710,11 @@ When you run `domstack --eject`, it will: It is recomended to eject early in your project so that you can customize the root layout as you see fit, and de-couple yourself from potential unwanted changes in the default layout as new versions of DOMStack are released. -### `--copy` directories +### `--copy` directories and files -You can specify directories to copy into your `dest` directory using the `--copy` flag. Everything in those directories will be copied as-is into the destination, including js, css, html and markdown, preserving the internal directory structure. Conflicting files are not detected or reported and will cause undefined behavior. +You can specify directories or individual files to copy into your `dest` directory using the `--copy` flag. Everything in those directories will be copied as-is into the destination, including js, css, html and markdown, preserving the internal directory structure. Individual files (such as a `_redirects` or `sw.js`) are copied to the root of the destination. Conflicting files are not detected or reported and will cause undefined behavior. -Copy folders must live **outside** of the `dest` directory. Copy directories can be in the src directory allowing for nested builds. In this case they are added to the ignore glob and ignored by the rest of `domstack`. +Copy paths must live **outside** of the `dest` directory. Copy directories can be in the src directory allowing for nested builds. In this case they are added to the ignore glob and ignored by the rest of `domstack`. This is useful when you have legacy or archived site content that you want to include in your site, but don't want `domstack` to process or modify it. In general, static content should live in your primary `src` directory, however for merging in old static assets over your domstack build is sometimes easier to reason about when it's kept in a separate folder and isn't processed in any way. diff --git a/bin.js b/bin.js index 560ef5d..ac31707 100755 --- a/bin.js +++ b/bin.js @@ -81,7 +81,7 @@ const options = { }, copy: { type: 'string', - help: 'path to directories to copy into dist; can be used multiple times', + help: 'path to directories or individual files to copy into dist; can be used multiple times', multiple: true }, help: { diff --git a/lib/build-copy/index.test.js b/lib/build-copy/index.test.js index 377e991..595a0ca 100644 --- a/lib/build-copy/index.test.js +++ b/lib/build-copy/index.test.js @@ -7,9 +7,14 @@ import { getCopyDirs } from './index.js' test.describe('build-copy', () => { test('getCopyDirs appends ** for non-existent paths', async () => { - const copyDirs = await getCopyDirs(['fixtures']) - - assert.deepStrictEqual(copyDirs, ['fixtures/**']) + const dir = await mkdtemp(join(os.tmpdir(), 'domstack-copy-test-')) + const missingPath = join(dir, 'does-not-exist') + try { + const copyDirs = await getCopyDirs([missingPath]) + assert.deepStrictEqual(copyDirs, [join(missingPath, '**')]) + } finally { + await rm(dir, { recursive: true, force: true }) + } }) test('getCopyDirs returns file path as-is for existing files', async () => {