A simple, folder-driven static-site engine.
bun ssg fs
at dev 103 lines 3.2 kB view raw
1/** 2 * Shared filesystem helpers used across webette. 3 * 4 * Small, focused utilities for writing/copying files and cleaning directories. 5 */ 6 7import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; 8import * as path from "node:path"; 9import type { Logger } from "../logging/logger"; 10 11const retryableRmCodes = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); 12 13const isRetryableRmError = (err: unknown): boolean => { 14 if (!err || typeof err !== "object") return false; 15 const code = (err as { code?: unknown }).code; 16 return typeof code === "string" && retryableRmCodes.has(code); 17}; 18 19const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 20 21export async function rmWithRetries( 22 targetPath: string, 23 options?: Parameters<typeof rm>[1], 24 retries = 5, 25 delayMs = 50 26): Promise<void> { 27 let attempt = 0; 28 while (true) { 29 try { 30 await rm(targetPath, options); 31 return; 32 } catch (err) { 33 if (attempt >= retries || !isRetryableRmError(err)) { 34 throw err; 35 } 36 const wait = delayMs * Math.pow(2, attempt); 37 await sleep(wait); 38 attempt += 1; 39 } 40 } 41} 42 43/** 44 * Writes UTF-8 content to `targetPath` only when the bytes differ. 45 * Returns `true` if the file changed, `false` if it was already identical. 46 */ 47export async function writeIfChanged( 48 targetPath: string, 49 content: string, 50 logger?: Logger 51): Promise<boolean> { 52 const nextBuffer = Buffer.from(content, "utf8"); 53 let current: Buffer | null = null; 54 try { 55 current = await readFile(targetPath); 56 } catch { 57 // file missing or unreadable -> treat as different 58 } 59 if (current && Buffer.compare(current, nextBuffer) === 0) { 60 logger?.debug("build.fileUnchanged", { path: targetPath }); 61 return false; 62 } 63 await writeFile(targetPath, nextBuffer, "utf8"); 64 logger?.debug("build.fileGenerated", { path: targetPath }); 65 return true; 66} 67 68/** 69 * Recursively removes empty directories under `root`, excluding `root` itself. 70 */ 71export async function removeEmptyDirectories(dir: string, root: string): Promise<void> { 72 const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); 73 for (const entry of entries) { 74 if (!entry.isDirectory()) continue; 75 const child = path.join(dir, entry.name); 76 await removeEmptyDirectories(child, root); 77 } 78 const after = await readdir(dir).catch(() => []); 79 if (dir !== root && after.length === 0) { 80 try { 81 await rmWithRetries(dir, { force: true, recursive: true }); 82 } catch { 83 // ignore 84 } 85 } 86} 87 88/** 89 * Recursively copies `sourceDir` into `targetDir`, creating directories as needed. 90 */ 91export async function copyDirectory(sourceDir: string, targetDir: string): Promise<void> { 92 const entries = await readdir(sourceDir, { withFileTypes: true }).catch(() => []); 93 await mkdir(targetDir, { recursive: true }); 94 for (const entry of entries) { 95 const srcPath = path.join(sourceDir, entry.name); 96 const dstPath = path.join(targetDir, entry.name); 97 if (entry.isDirectory()) { 98 await copyDirectory(srcPath, dstPath); 99 } else if (entry.isFile()) { 100 await copyFile(srcPath, dstPath); 101 } 102 } 103}