From 0c1171801fa596b2fe53361c8e709120f5731b6f Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Mon, 3 Nov 2025 23:52:29 +0100 Subject: [PATCH 01/12] chore: a tiny bit safer approach --- src/generators/legacy-html/index.mjs | 7 +- .../utils/__tests__/safeCopy.test.mjs | 126 ++++++++++++++++++ src/generators/legacy-html/utils/safeCopy.mjs | 11 +- 3 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs diff --git a/src/generators/legacy-html/index.mjs b/src/generators/legacy-html/index.mjs index c100967b..36b1e8d9 100644 --- a/src/generators/legacy-html/index.mjs +++ b/src/generators/legacy-html/index.mjs @@ -1,6 +1,6 @@ 'use strict'; -import { readFile, rm, writeFile, mkdir } from 'node:fs/promises'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import HTMLMinifier from '@minify-html/node'; @@ -176,11 +176,6 @@ export default { // Define the output folder for API docs assets const assetsFolder = join(output, 'assets'); - // Removes the current assets directory to copy the new assets - // and prevent stale assets from existing in the output directory - // If the path does not exists, it will simply ignore and continue - await rm(assetsFolder, { recursive: true, force: true, maxRetries: 10 }); - // Creates the assets folder if it does not exist await mkdir(assetsFolder, { recursive: true }); diff --git a/src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs b/src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs new file mode 100644 index 00000000..12a8736f --- /dev/null +++ b/src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs @@ -0,0 +1,126 @@ +'use strict'; + +import assert from 'node:assert'; +import { mkdir, readFile, rm, utimes, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; + +import { safeCopy } from '../safeCopy.mjs'; + +describe('safeCopy', () => { + const testDir = join(import.meta.dirname, 'test-safe-copy'); + const srcDir = join(testDir, 'src'); + const targetDir = join(testDir, 'target'); + + beforeEach(async () => { + // Create test directories + await mkdir(srcDir, { recursive: true }); + await mkdir(targetDir, { recursive: true }); + }); + + afterEach(async () => { + // Clean up test directories + await rm(testDir, { recursive: true, force: true }); + }); + + it('should copy new files that do not exist in target', async () => { + // Create a file in source + await writeFile(join(srcDir, 'file1.txt'), 'content1'); + + await safeCopy(srcDir, targetDir); + + // Verify file was copied + const content = await readFile(join(targetDir, 'file1.txt'), 'utf-8'); + assert.strictEqual(content, 'content1'); + }); + + it('should copy multiple files', async () => { + // Create multiple files in source + await writeFile(join(srcDir, 'file1.txt'), 'content1'); + await writeFile(join(srcDir, 'file2.txt'), 'content2'); + await writeFile(join(srcDir, 'file3.txt'), 'content3'); + + await safeCopy(srcDir, targetDir); + + // Verify all files were copied + const content1 = await readFile(join(targetDir, 'file1.txt'), 'utf-8'); + const content2 = await readFile(join(targetDir, 'file2.txt'), 'utf-8'); + const content3 = await readFile(join(targetDir, 'file3.txt'), 'utf-8'); + + assert.strictEqual(content1, 'content1'); + assert.strictEqual(content2, 'content2'); + assert.strictEqual(content3, 'content3'); + }); + + it('should skip files with same size and older modification time', async () => { + // Create file in source with specific size + const content = 'same content'; + await writeFile(join(srcDir, 'file1.txt'), content); + + // Make source file old + const oldTime = new Date(Date.now() - 10000); + await utimes(join(srcDir, 'file1.txt'), oldTime, oldTime); + + // Create target file with same size but different content and newer timestamp + await writeFile(join(targetDir, 'file1.txt'), 'other things'); + + await safeCopy(srcDir, targetDir); + + // Verify file was not overwritten (source is older) + const targetContent = await readFile(join(targetDir, 'file1.txt'), 'utf-8'); + assert.strictEqual(targetContent, 'other things'); + }); + + it('should copy files when source has newer modification time', async () => { + // Create files in both directories + await writeFile(join(srcDir, 'file1.txt'), 'new content'); + await writeFile(join(targetDir, 'file1.txt'), 'old content'); + + // Make target file older + const oldTime = new Date(Date.now() - 10000); + await utimes(join(targetDir, 'file1.txt'), oldTime, oldTime); + + await safeCopy(srcDir, targetDir); + + // Verify file was updated + const content = await readFile(join(targetDir, 'file1.txt'), 'utf-8'); + assert.strictEqual(content, 'new content'); + }); + + it('should copy files when sizes differ', async () => { + // Create files with different sizes + await writeFile(join(srcDir, 'file1.txt'), 'short'); + await writeFile(join(targetDir, 'file1.txt'), 'much longer content'); + + await safeCopy(srcDir, targetDir); + + // Verify file was updated + const content = await readFile(join(targetDir, 'file1.txt'), 'utf-8'); + assert.strictEqual(content, 'short'); + }); + + it('should handle empty source directory', async () => { + // Don't create any files in source + await safeCopy(srcDir, targetDir); + + // Verify no error occurred and target is still empty + const files = await readFile(targetDir).catch(() => []); + assert.ok(Array.isArray(files) || files === undefined); + }); + + it('should copy files with same size but different content when mtime is newer', async () => { + // Create files with same size but different content + await writeFile(join(srcDir, 'file1.txt'), 'abcde'); + await writeFile(join(targetDir, 'file1.txt'), 'fghij'); + + // Make target older + const oldTime = new Date(Date.now() - 10000); + await utimes(join(targetDir, 'file1.txt'), oldTime, oldTime); + + await safeCopy(srcDir, targetDir); + + // Verify file was updated with source content + const content = await readFile(join(targetDir, 'file1.txt'), 'utf-8'); + assert.strictEqual(content, 'abcde'); + }); +}); diff --git a/src/generators/legacy-html/utils/safeCopy.mjs b/src/generators/legacy-html/utils/safeCopy.mjs index e96b62ed..182d2611 100644 --- a/src/generators/legacy-html/utils/safeCopy.mjs +++ b/src/generators/legacy-html/utils/safeCopy.mjs @@ -1,11 +1,12 @@ 'use strict'; -import { readFile, writeFile, stat, readdir } from 'node:fs/promises'; +import { copyFile, readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; /** * Safely copies files from source to target directory, skipping files that haven't changed - * based on file stats (size and modification time) + * based on file stats (size and modification time). Uses native fs.copyFile which handles + * concurrent operations gracefully. * * @param {string} srcDir - Source directory path * @param {string} targetDir - Target directory path @@ -31,8 +32,8 @@ export async function safeCopy(srcDir, targetDir) { continue; } - const fileContent = await readFile(sourcePath); - - await writeFile(targetPath, fileContent); + // Use copyFile with COPYFILE_FICLONE flag for efficient copying + // This is atomic and handles concurrent operations better than manual read/write + await copyFile(sourcePath, targetPath); } } From a11e966eab9e3053b1f49da090a82b40911f0d79 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Mon, 3 Nov 2025 18:04:03 -0500 Subject: [PATCH 02/12] fixup! --- src/generators/legacy-html/utils/safeCopy.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/generators/legacy-html/utils/safeCopy.mjs b/src/generators/legacy-html/utils/safeCopy.mjs index 182d2611..7aba932b 100644 --- a/src/generators/legacy-html/utils/safeCopy.mjs +++ b/src/generators/legacy-html/utils/safeCopy.mjs @@ -1,6 +1,6 @@ 'use strict'; -import { copyFile, readdir, stat } from 'node:fs/promises'; +import { copyFile, readdir, stat, constants } from 'node:fs/promises'; import { join } from 'node:path'; /** @@ -34,6 +34,6 @@ export async function safeCopy(srcDir, targetDir) { // Use copyFile with COPYFILE_FICLONE flag for efficient copying // This is atomic and handles concurrent operations better than manual read/write - await copyFile(sourcePath, targetPath); + await copyFile(sourcePath, targetPath, constants.COPYFILE_FICLONE); } } From f739149e01af521b4e1809becab3d3d86441a3b0 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Wed, 5 Nov 2025 16:45:30 -0500 Subject: [PATCH 03/12] @aduh95 suggestion --- src/generators/legacy-html/utils/safeCopy.mjs | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/src/generators/legacy-html/utils/safeCopy.mjs b/src/generators/legacy-html/utils/safeCopy.mjs index 7aba932b..96ff6deb 100644 --- a/src/generators/legacy-html/utils/safeCopy.mjs +++ b/src/generators/legacy-html/utils/safeCopy.mjs @@ -4,36 +4,40 @@ import { copyFile, readdir, stat, constants } from 'node:fs/promises'; import { join } from 'node:path'; /** - * Safely copies files from source to target directory, skipping files that haven't changed - * based on file stats (size and modification time). Uses native fs.copyFile which handles - * concurrent operations gracefully. + * Attempts to copy a file forcibly (`COPYFILE_FICLONE_FORCE`. Otherwise, falls back to a time-based check approach) * * @param {string} srcDir - Source directory path * @param {string} targetDir - Target directory path */ export async function safeCopy(srcDir, targetDir) { - const files = await readdir(srcDir); + try { + await copyFile(srcDir, targetDir, constants.COPYFILE_FICLONE); + } catch (err) { + if (err?.syscall !== 'copyfile') { + throw err; + } - for (const file of files) { - const sourcePath = join(srcDir, file); - const targetPath = join(targetDir, file); + const files = await readdir(srcDir); - const [sStat, tStat] = await Promise.allSettled([ - stat(sourcePath), - stat(targetPath), - ]); + for (const file of files) { + const sourcePath = join(srcDir, file); + const targetPath = join(targetDir, file); - const shouldWrite = - tStat.status === 'rejected' || - sStat.value.size !== tStat.value.size || - sStat.value.mtimeMs > tStat.value.mtimeMs; + const [sStat, tStat] = await Promise.allSettled([ + stat(sourcePath), + stat(targetPath), + ]); - if (!shouldWrite) { - continue; - } + const shouldWrite = + tStat.status === 'rejected' || + sStat.value.size !== tStat.value.size || + sStat.value.mtimeMs > tStat.value.mtimeMs; - // Use copyFile with COPYFILE_FICLONE flag for efficient copying - // This is atomic and handles concurrent operations better than manual read/write - await copyFile(sourcePath, targetPath, constants.COPYFILE_FICLONE); + if (!shouldWrite) { + continue; + } + + await copyFile(sourcePath, targetPath); + } } } From acd9eef5e59bfbde5b713a28167c8db9d15ec78c Mon Sep 17 00:00:00 2001 From: avivkeller Date: Wed, 5 Nov 2025 16:55:15 -0500 Subject: [PATCH 04/12] fixup! --- src/generators/legacy-html/utils/safeCopy.mjs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/generators/legacy-html/utils/safeCopy.mjs b/src/generators/legacy-html/utils/safeCopy.mjs index 96ff6deb..7c408f5d 100644 --- a/src/generators/legacy-html/utils/safeCopy.mjs +++ b/src/generators/legacy-html/utils/safeCopy.mjs @@ -23,14 +23,13 @@ export async function safeCopy(srcDir, targetDir) { const sourcePath = join(srcDir, file); const targetPath = join(targetDir, file); - const [sStat, tStat] = await Promise.allSettled([ + const [sStat, tStat] = await Promise.all([ stat(sourcePath), stat(targetPath), - ]); + ]).catch(() => []); - const shouldWrite = - tStat.status === 'rejected' || - sStat.value.size !== tStat.value.size || + const shouldWrite = !tStat; + sStat.value.size !== tStat.value.size || sStat.value.mtimeMs > tStat.value.mtimeMs; if (!shouldWrite) { From 17629fbc8ce3a0dd37ef87d0b2ce558338babe93 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Wed, 5 Nov 2025 16:56:00 -0500 Subject: [PATCH 05/12] fixup! --- src/generators/legacy-html/utils/safeCopy.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/generators/legacy-html/utils/safeCopy.mjs b/src/generators/legacy-html/utils/safeCopy.mjs index 7c408f5d..9286e453 100644 --- a/src/generators/legacy-html/utils/safeCopy.mjs +++ b/src/generators/legacy-html/utils/safeCopy.mjs @@ -28,8 +28,9 @@ export async function safeCopy(srcDir, targetDir) { stat(targetPath), ]).catch(() => []); - const shouldWrite = !tStat; - sStat.value.size !== tStat.value.size || + const shouldWrite = + !tStat || + sStat.value.size !== tStat.value.size || sStat.value.mtimeMs > tStat.value.mtimeMs; if (!shouldWrite) { From eaa717cff5bb079188bf6724a21bb1ee795fe8f9 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Wed, 5 Nov 2025 17:23:35 -0500 Subject: [PATCH 06/12] Update safeCopy.mjs Co-authored-by: Antoine du Hamel --- src/generators/legacy-html/utils/safeCopy.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/generators/legacy-html/utils/safeCopy.mjs b/src/generators/legacy-html/utils/safeCopy.mjs index 9286e453..69553279 100644 --- a/src/generators/legacy-html/utils/safeCopy.mjs +++ b/src/generators/legacy-html/utils/safeCopy.mjs @@ -30,8 +30,8 @@ export async function safeCopy(srcDir, targetDir) { const shouldWrite = !tStat || - sStat.value.size !== tStat.value.size || - sStat.value.mtimeMs > tStat.value.mtimeMs; + sStat.size !== tStat.size || + sStat.mtimeMs > tStat.mtimeMs; if (!shouldWrite) { continue; From 8d4d28917c3a5eb329301e4528f4d4cebd6d82ee Mon Sep 17 00:00:00 2001 From: avivkeller Date: Wed, 5 Nov 2025 17:50:16 -0500 Subject: [PATCH 07/12] fixup! --- src/generators/legacy-html/utils/safeCopy.mjs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/generators/legacy-html/utils/safeCopy.mjs b/src/generators/legacy-html/utils/safeCopy.mjs index 69553279..634a5654 100644 --- a/src/generators/legacy-html/utils/safeCopy.mjs +++ b/src/generators/legacy-html/utils/safeCopy.mjs @@ -29,9 +29,7 @@ export async function safeCopy(srcDir, targetDir) { ]).catch(() => []); const shouldWrite = - !tStat || - sStat.size !== tStat.size || - sStat.mtimeMs > tStat.mtimeMs; + !tStat || sStat.size !== tStat.size || sStat.mtimeMs > tStat.mtimeMs; if (!shouldWrite) { continue; From eed1d8f2540477a9ef3a04b038853a08cdfa7ad1 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Thu, 6 Nov 2025 19:26:49 +0100 Subject: [PATCH 08/12] chore: updated safeCopy --- .../utils/__tests__/safeCopy.test.mjs | 5 +-- src/generators/legacy-html/utils/safeCopy.mjs | 39 ++++++++----------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs b/src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs index 12a8736f..05b7aaae 100644 --- a/src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs +++ b/src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs @@ -103,9 +103,8 @@ describe('safeCopy', () => { // Don't create any files in source await safeCopy(srcDir, targetDir); - // Verify no error occurred and target is still empty - const files = await readFile(targetDir).catch(() => []); - assert.ok(Array.isArray(files) || files === undefined); + // Verify no error occurred - if we get here, the function succeeded + assert.ok(true); }); it('should copy files with same size but different content when mtime is newer', async () => { diff --git a/src/generators/legacy-html/utils/safeCopy.mjs b/src/generators/legacy-html/utils/safeCopy.mjs index 634a5654..0d515940 100644 --- a/src/generators/legacy-html/utils/safeCopy.mjs +++ b/src/generators/legacy-html/utils/safeCopy.mjs @@ -1,40 +1,35 @@ 'use strict'; -import { copyFile, readdir, stat, constants } from 'node:fs/promises'; +import { copyFile, readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; /** - * Attempts to copy a file forcibly (`COPYFILE_FICLONE_FORCE`. Otherwise, falls back to a time-based check approach) + * Copies files from source to target directory, skipping files that haven't changed. + * Uses synchronous stat checks for simplicity and copyFile for atomic operations. * * @param {string} srcDir - Source directory path * @param {string} targetDir - Target directory path */ export async function safeCopy(srcDir, targetDir) { - try { - await copyFile(srcDir, targetDir, constants.COPYFILE_FICLONE); - } catch (err) { - if (err?.syscall !== 'copyfile') { - throw err; - } - - const files = await readdir(srcDir); + const files = await readdir(srcDir); - for (const file of files) { - const sourcePath = join(srcDir, file); - const targetPath = join(targetDir, file); + for (const file of files) { + const sourcePath = join(srcDir, file); + const targetPath = join(targetDir, file); - const [sStat, tStat] = await Promise.all([ - stat(sourcePath), - stat(targetPath), - ]).catch(() => []); + const tStat = await stat(targetPath).catch(() => undefined); - const shouldWrite = - !tStat || sStat.size !== tStat.size || sStat.mtimeMs > tStat.mtimeMs; + // If target doesn't exist, copy immediately + if (!tStat) { + await copyFile(sourcePath, targetPath); + continue; + } - if (!shouldWrite) { - continue; - } + // Target exists, check if we need to update + const sStat = await stat(sourcePath); + // Skip if target has same size and source is not newer + if (sStat.size !== tStat.size || sStat.mtimeMs > tStat.mtimeMs) { await copyFile(sourcePath, targetPath); } } From d519966c725171c52f2747bd6590e9ab3fd2bc74 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Thu, 6 Nov 2025 19:29:08 +0100 Subject: [PATCH 09/12] chore: more simplification --- src/generators/legacy-html/utils/safeCopy.mjs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/generators/legacy-html/utils/safeCopy.mjs b/src/generators/legacy-html/utils/safeCopy.mjs index 0d515940..a99bdbef 100644 --- a/src/generators/legacy-html/utils/safeCopy.mjs +++ b/src/generators/legacy-html/utils/safeCopy.mjs @@ -19,16 +19,13 @@ export async function safeCopy(srcDir, targetDir) { const tStat = await stat(targetPath).catch(() => undefined); - // If target doesn't exist, copy immediately - if (!tStat) { + if (tStat === undefined) { await copyFile(sourcePath, targetPath); continue; } - // Target exists, check if we need to update const sStat = await stat(sourcePath); - // Skip if target has same size and source is not newer if (sStat.size !== tStat.size || sStat.mtimeMs > tStat.mtimeMs) { await copyFile(sourcePath, targetPath); } From c6dd1801bbbaf994d3a694aafcd1dfaa7006cfc4 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Thu, 6 Nov 2025 19:32:07 +0100 Subject: [PATCH 10/12] chore: more changes --- src/generators/legacy-html/utils/safeCopy.mjs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/generators/legacy-html/utils/safeCopy.mjs b/src/generators/legacy-html/utils/safeCopy.mjs index a99bdbef..fcf62e7d 100644 --- a/src/generators/legacy-html/utils/safeCopy.mjs +++ b/src/generators/legacy-html/utils/safeCopy.mjs @@ -1,6 +1,7 @@ 'use strict'; -import { copyFile, readdir, stat } from 'node:fs/promises'; +import { statSync, constants } from 'node:fs'; +import { copyFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; /** @@ -17,17 +18,17 @@ export async function safeCopy(srcDir, targetDir) { const sourcePath = join(srcDir, file); const targetPath = join(targetDir, file); - const tStat = await stat(targetPath).catch(() => undefined); + const tStat = statSync(targetPath, { throwIfNoEntry: false }); if (tStat === undefined) { - await copyFile(sourcePath, targetPath); + await copyFile(sourcePath, targetPath, constants.COPYFILE_FICLONE); continue; } - const sStat = await stat(sourcePath); + const sStat = statSync(sourcePath); if (sStat.size !== tStat.size || sStat.mtimeMs > tStat.mtimeMs) { - await copyFile(sourcePath, targetPath); + await copyFile(sourcePath, targetPath, constants.COPYFILE_FICLONE); } } } From 1cadf7204a274c101baef32d08012ec0489c0e76 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Sun, 9 Nov 2025 02:54:46 +0100 Subject: [PATCH 11/12] chore: apply code review --- src/generators/legacy-html/utils/safeCopy.mjs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/generators/legacy-html/utils/safeCopy.mjs b/src/generators/legacy-html/utils/safeCopy.mjs index fcf62e7d..e429912f 100644 --- a/src/generators/legacy-html/utils/safeCopy.mjs +++ b/src/generators/legacy-html/utils/safeCopy.mjs @@ -14,21 +14,22 @@ import { join } from 'node:path'; export async function safeCopy(srcDir, targetDir) { const files = await readdir(srcDir); - for (const file of files) { + const promises = files.map(file => { const sourcePath = join(srcDir, file); const targetPath = join(targetDir, file); const tStat = statSync(targetPath, { throwIfNoEntry: false }); if (tStat === undefined) { - await copyFile(sourcePath, targetPath, constants.COPYFILE_FICLONE); - continue; + return copyFile(sourcePath, targetPath, constants.COPYFILE_FICLONE); } const sStat = statSync(sourcePath); if (sStat.size !== tStat.size || sStat.mtimeMs > tStat.mtimeMs) { - await copyFile(sourcePath, targetPath, constants.COPYFILE_FICLONE); + return copyFile(sourcePath, targetPath, constants.COPYFILE_FICLONE); } - } + }); + + await Promise.all(promises); } From 190b2664a27be8899180637f0cc3835da31b69c6 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Sun, 9 Nov 2025 02:56:52 +0100 Subject: [PATCH 12/12] chore: removed stupid assert --- src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs b/src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs index 05b7aaae..186020a8 100644 --- a/src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs +++ b/src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs @@ -102,9 +102,6 @@ describe('safeCopy', () => { it('should handle empty source directory', async () => { // Don't create any files in source await safeCopy(srcDir, targetDir); - - // Verify no error occurred - if we get here, the function succeeded - assert.ok(true); }); it('should copy files with same size but different content when mtime is newer', async () => {