Skip to content
Merged
7 changes: 1 addition & 6 deletions src/generators/legacy-html/index.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 });

Expand Down
126 changes: 126 additions & 0 deletions src/generators/legacy-html/utils/__tests__/safeCopy.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
});
});
43 changes: 23 additions & 20 deletions src/generators/legacy-html/utils/safeCopy.mjs
Original file line number Diff line number Diff line change
@@ -1,38 +1,41 @@
'use strict';

import { readFile, writeFile, stat, readdir } from 'node:fs/promises';
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)
* 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.all([
stat(sourcePath),
stat(targetPath),
]).catch(() => []);

if (!shouldWrite) {
continue;
}
const shouldWrite =
!tStat || sStat.size !== tStat.size || sStat.mtimeMs > tStat.mtimeMs;

const fileContent = await readFile(sourcePath);
if (!shouldWrite) {
continue;
}

await writeFile(targetPath, fileContent);
await copyFile(sourcePath, targetPath);
}
}
}
Loading