import path from "node:path"; import fs from "fs-extra"; /** * Serialize a file name for inclusion in a ZIP. * * @param text The file name to serialize. * @returns The serialized file name. */ export function serializeFilename(text: string): string { return text.replace(/\//g, "%2F").replace(/\\/g, "%5C"); } /** * Deserialize a file name serialized with `serializeFilename`. * * @param text The file name to deserialize. * @returns The deserialized file name. */ export function deserializeFilename(text: string): string { return text.replace(/%2F/g, "/").replace(/%5C/g, "\\"); } /** * Get the UTF8 byte length of a string. * * @param str The string to measure. * @returns The byte length of the string. */ export function stringByteLength(str: string): number { return Buffer.byteLength(str, "utf8"); } /** * Safely slice a string to a maximum byte length without breaking UTF-8 characters. * * @param str The string to slice. * @param maxBytes The maximum byte length. * @returns The sliced string. */ function sliceStringToByteLength(str: string, maxBytes: number): string { if (maxBytes <= 0) { return ""; } const buffer = Buffer.from(str, "utf8"); if (buffer.length <= maxBytes) { return str; } // Work backwards from maxBytes to find valid UTF-8 boundary for (let i = maxBytes; i > 0; i--) { const slice = buffer.subarray(0, i); const result = slice.toString("utf8"); // Check if the result round-trips correctly (no replacement characters) if (Buffer.from(result, "utf8").equals(slice)) { return result; } } return ""; } /** * Trim a file name to a maximum length, retaining the extension. The input * must be a filename only — passing a path (containing `/` or `\`) will throw. * * @param text The file name to trim. * @param length The maximum length of the file name in bytes. * @returns The trimmed file name. * @throws If `text` contains a path separator. */ export function trimFilenameAndExt(text: string, length: number): string { if (text.includes("/") || text.includes("\\")) { throw new Error( "trimFilenameAndExt expects a filename without path separators" ); } if (Buffer.byteLength(text, "utf8") > length) { const ext = path.extname(text); const name = path.basename(text, ext); const extByteLength = Buffer.byteLength(ext, "utf8"); const availableBytesForName = length - extByteLength; if (availableBytesForName <= 0) { // If extension is too long, trim the whole filename return sliceStringToByteLength(text, length); } const trimmedName = sliceStringToByteLength(name, availableBytesForName); return trimmedName + ext; } return text; } /** * Get a list of file names in a directory. * * @param dirName The directory to search. * @returns A list of file names in the directory. */ export function getFilenamesInDirectory(dirName: string): string[] { return fs .readdirSync(dirName) .filter( (file) => file.indexOf(".") !== 0 && file.match(/\.[jt]s$/) && file !== path.basename(__filename) && !file.includes(".test") ); } // Optional cache used in tests, where Node's require() cannot resolve // TypeScript files with aliased imports. Populated by the test setup with // modules pre-loaded via Vite's import.meta.glob, keyed by directory suffix. const requireDirectoryCache = new Map>(); /** * Pre-populate requireDirectory's module cache. Intended for use only by the * Vitest test setup; production code should not call this. * * @param suffix The directory path suffix to match against. * @param modules The eagerly-loaded modules. */ export function __setRequireDirectoryCache( suffix: string, modules: Record ): void { requireDirectoryCache.set(suffix, modules); } /** * Require all files in a directory and return them as an array of tuples. * * @param dirName The directory to search. * @returns An array of tuples containing the required files and their names. */ export function requireDirectory(dirName: string): [T, string][] { for (const [suffix, modules] of requireDirectoryCache) { if (dirName.endsWith(suffix)) { return Object.entries(modules) .filter( ([filePath]) => !filePath.endsWith("/index.ts") && !filePath.endsWith("/index.js") && !filePath.includes(".test.") ) .map(([filePath, mod]) => { const base = filePath.split("/").pop() ?? filePath; const id = base.replace(/\.[jt]s$/, ""); return [mod as T, id]; }); } } return getFilenamesInDirectory(dirName).map((fileName) => { const filePath = path.join(dirName, fileName); const name = path.basename(filePath.replace(/\.[jt]s$/, "")); return [require(filePath), name]; }); }