UNPKG

vite-static-assets-plugin

Version:
398 lines (389 loc) 20.1 kB
import * as fs from 'node:fs'; import * as path from 'node:path'; import { normalizePath } from 'vite'; // Using Vite's normalizePath import { minimatch } from 'minimatch'; import chalk from 'chalk'; import chokidar from 'chokidar'; /** * Asynchronously scan a directory and return all file paths (using Vite's normalizePath). * Based on the user's original function. */ async function getAllFiles(dir, baseDir, ignorePatterns = []) { const files = []; try { // Use withFileTypes for potential efficiency later if needed, but stick to readdir for now const items = await fs.promises.readdir(dir); const itemPromises = items.map(async (item) => { try { const fullPath = path.join(dir, item); // Use Vite's normalizePath for consistency AFTER getting relative path const relativePath = normalizePath(path.relative(baseDir, fullPath)); const shouldIgnore = ignorePatterns.some(pattern => minimatch(relativePath, pattern, { dot: true })); if (shouldIgnore) { return []; } const stat = await fs.promises.stat(fullPath); if (stat.isDirectory()) { const subFiles = await getAllFiles(fullPath, baseDir, ignorePatterns); return subFiles; } // Only add if it's a file (original logic) return [relativePath]; } catch (err) { console.warn(`${chalk.yellow('⚠')} Error processing item ${item}: ${err instanceof Error ? err.message : err}`); return []; // Continue with other files } }); const nestedResults = await Promise.all(itemPromises); return nestedResults.flat(); } catch (err) { const error = err; // Log directory read error but don't halt unless critical if (err instanceof Error && error.code !== 'ENOENT') { console.error(`${chalk.red('✗')} Error reading directory ${dir}: ${err.message}`); } else if (err instanceof Error && error.code === 'ENOENT') { // Don't log error if dir doesn't exist, handled in buildStart } else { console.error(`${chalk.red('✗')} Error reading directory ${dir}: ${err}`); } return []; // Return empty array on directory read failure } } /** * Extracts directory paths from a list of file paths. * Uses '.' to represent the root directory. * (Modified from previous attempts to include root '.') */ function extractDirectories(files, maxDepth = 5) { const directories = new Set(); let hasRootFiles = false; // Flag to check if root files exist for (const file of files) { // Use posix path handling for consistency within types const dirPath = path.posix.dirname(file); // Check for root files (path contains no slashes, dirname is '.') if (dirPath === '.') { hasRootFiles = true; continue; // Don't process '.' further in the loop } const parts = dirPath.split('/'); let currentPath = ''; // Generate parent directory paths up to maxDepth for (let i = 0; i < Math.min(parts.length, maxDepth); i++) { if (parts[i] === '') continue; // Skip empty parts currentPath += `${parts[i]}/`; // Ensure trailing slash for directory paths directories.add(currentPath); } } // Add '.' to represent the root directory if files exist there if (hasRootFiles) { directories.add('.'); } return directories; } /** * Generates the TypeScript code content including FilesInFolder generic. * Based on user's original function structure + generic addition. */ function generateTypeScriptCode(files, sourceDirAbsolutePath, // For error messages basePath = '/', // From Vite config options = {}) { const { enableDirectoryTypes = true, // Keep this enabled for FilesInFolder maxDirectoryDepth = 5, addLeadingSlash = true, // From original options } = options; const sortedFiles = [...files].sort(); // Sort for consistency const fileList = sortedFiles.length > 0 ? sortedFiles.map(file => ` '${file}'`).join(' |\n') : 'never'; let directoryTypesCode = ''; let filesInFolderGenericCode = ''; // For the new generic // enableDirectoryTypes is required for the generic to work if (enableDirectoryTypes) { const directories = extractDirectories(sortedFiles, maxDirectoryDepth); // Generate StaticAssetDirectory type if (directories.size > 0 || sortedFiles.some(f => !f.includes('/'))) { const directoryList = Array.from(directories) .sort() .map(dir => ` '${dir}'`) // Use single quotes .join(' |\n'); const staticAssetDirectoryType = directoryList.length > 0 ? directoryList : 'never'; directoryTypesCode = ` /** * Represents the known directories containing static assets. * '.' represents the root directory. */ export type StaticAssetDirectory = ${staticAssetDirectoryType};`; // Define the FilesInFolder Generic filesInFolderGenericCode = ` /** * Represents the relative paths of files located *directly* within a specific directory. * Use '.' for the root directory. * @template Dir - A directory path string literal type from StaticAssetDirectory (e.g., 'icons/', 'icons/sun/', '.'). */ export type FilesInFolder<Dir extends '.' | StaticAssetDirectory> = Dir extends '.' ? Exclude<StaticAssetPath, \`$\{string}/$\{string}\`> : Extract<StaticAssetPath, \`$\{Dir}$\{string}\`> extends infer Match ? Match extends \`$\{Dir}$\{infer FileName}\` ? FileName extends \`$\{string}/$\{string}\` ? never : Match : never : never; `; } // End if directories or root files exist } // End if enableDirectoryTypes // --- Assemble the final code --- // Based on original structure return `// This file is auto-generated. Do not edit it manually. export type StaticAssetPath = ${fileList}; ${directoryTypesCode /* Includes StaticAssetDirectory */} ${filesInFolderGenericCode /* Includes FilesInFolder */} const assets = new Set<string>([ ${sortedFiles.map(file => ` '${file}'`).join(',\n')} ]); // Store basePath resolved from Vite config const BASE_PATH = ${JSON.stringify(basePath)}; /** * Gets the URL for a specific static asset * @param path Path to the asset * @returns The URL for the asset */ export function staticAssets(path: StaticAssetPath): string { if (!assets.has(path)) { throw new Error( "Static asset does not exist in static assets directory" ); } return \`\${BASE_PATH}\${path}\`; } `; } // Export these functions for potential testing (as in original) export { getAllFiles, generateTypeScriptCode }; // --- Main Plugin Function (Based on Original Structure) --- export default function staticAssetsPlugin(options = {}) { // Resolve paths relative to CWD const directory = path.resolve(process.cwd(), options.directory || 'public'); const outputFile = path.resolve(process.cwd(), options.outputFile || 'src/static-assets.ts'); const ignorePatterns = options.ignore || ['.DS_Store']; const enableDirectoryTypes = options.enableDirectoryTypes !== false; // Default true // State variables let watcher = null; let currentFiles = new Set(); let basePath = '/'; // Default Vite base path let isBuild = false; let debounceTimer = null; // Helper to ensure output file and directory exist (original structure) const ensureOutputFile = async () => { const outputDir = path.dirname(outputFile); try { await fs.promises.mkdir(outputDir, { recursive: true }); try { await fs.promises.access(outputFile); } catch { await fs.promises.writeFile(outputFile, '// Initial placeholder\n'); console.log(`${chalk.cyan('ℹ')} Created placeholder file: ${chalk.blue(normalizePath(path.relative(process.cwd(), outputFile)))}`); } } catch (err) { // Make error message clearer throw new Error(`[vite-plugin-static-assets] Failed to ensure output file/directory (${normalizePath(path.relative(process.cwd(), outputFile))}): ${err instanceof Error ? err.message : err}`); } }; // Initial setup called before buildStart ensureOutputFile().catch(err => { console.error(`${chalk.red('✗')} ${err.message}`); // Consider exiting if setup fails critically // process.exit(1); }); return { name: 'vite-plugin-static-assets', // Get Vite config like base path configResolved(resolvedConfig) { basePath = resolvedConfig.base || '/'; isBuild = resolvedConfig.command === 'build'; // Ensure basePath ends with / unless it's just "/" for simple joining later if (basePath !== '/' && !basePath.endsWith('/')) { basePath += '/'; } }, // Scan directory, generate types, setup watcher async buildStart() { try { // Ensure source directory exists before scanning try { await fs.promises.access(directory); } catch (e) { const error = e; // Log warning if dir doesn't exist, but don't throw unless other error if (e instanceof Error && error.code === 'ENOENT') { console.warn(`${chalk.yellow('⚠')} [vite-plugin-static-assets] Source directory "${options.directory || 'public'}" not found. Generating empty types.`); } else { throw new Error(`[vite-plugin-static-assets] Error accessing source directory "${options.directory || 'public'}": ${e instanceof Error ? e.message : e}`); } currentFiles = new Set(); // Ensure files are empty if dir doesn't exist } // Scan files only if directory exists const files = fs.existsSync(directory) ? await getAllFiles(directory, directory, ignorePatterns) : []; currentFiles = new Set(files); const code = generateTypeScriptCode(files, directory, basePath, { ...options, enableDirectoryTypes }); // Pass resolved options await ensureOutputFile(); // Re-ensure just in case await fs.promises.writeFile(outputFile, code); console.log(`${chalk.green('✓')} Generated static assets types at ${chalk.blue(normalizePath(path.relative(process.cwd(), outputFile)))} (${currentFiles.size} assets)`); // Setup watcher in dev mode (not build) - Original logic if (!isBuild && !watcher) { try { watcher = chokidar.watch(directory, { ignored: ignorePatterns.map(pattern => path.join(directory, pattern)), // Use absolute paths for ignore ignoreInitial: true, persistent: true, // Using default awaitWriteFinish might be safer awaitWriteFinish: true, }); const updateTypeScriptFile = async (eventType) => { if (debounceTimer) clearTimeout(debounceTimer); // Clear existing timer debounceTimer = setTimeout(async () => { try { console.log(`${chalk.cyan('ℹ')} [vite-plugin-static-assets] Change detected (${eventType}), regenerating types...`); const updatedFiles = await getAllFiles(directory, directory, ignorePatterns); const updatedCode = generateTypeScriptCode(updatedFiles, directory, basePath, { ...options, enableDirectoryTypes }); // Avoid writing if content is identical const currentContent = await fs.promises.readFile(outputFile, 'utf-8').catch(() => ''); // Handle read error if file deleted if (currentContent !== updatedCode) { await fs.promises.writeFile(outputFile, updatedCode); currentFiles = new Set(updatedFiles); // Update cache console.log(`${chalk.green('✓')} Updated static assets type definitions (${eventType}) - ${currentFiles.size} assets.`); } else { console.log(`${chalk.gray('✓')} [vite-plugin-static-assets] No changes in generated types.`); } } catch (err) { console.error(`${chalk.red('✗')} Error updating static assets: ${err instanceof Error ? err.message : err}`); } }, options.debounce ?? 200); // Use debounce option or default }; watcher .on('add', () => updateTypeScriptFile("add")) .on('unlink', () => updateTypeScriptFile('unlink')) .on('change', () => updateTypeScriptFile('change')) .on('addDir', () => updateTypeScriptFile('add')) // Regenerate on dir changes too .on('unlinkDir', () => updateTypeScriptFile('unlink')) .on('error', (error) => { console.error(`${chalk.red('✗')} Watcher error: ${error}`); }); console.log(`${chalk.cyan('ℹ')} [vite-plugin-static-assets] Watching for changes in ${chalk.blue(normalizePath(path.relative(process.cwd(), directory)))}`); } catch (err) { console.error(`${chalk.red('✗')} Error setting up file watcher: ${err instanceof Error ? err.message : err}`); } } } catch (err) { // Log and re-throw critical errors during setup console.error(`${chalk.red('✗')} [vite-plugin-static-assets] Error during buildStart: ${err instanceof Error ? err.message : err}`); throw err; } }, // Validate asset usage - Original logic transform(code, id) { // Skip node_modules and the output file itself if (id.includes('node_modules') || normalizePath(id) === normalizePath(outputFile)) { return null; } // Only process relevant files if (!/\.(?:[jt]sx?|vue|svelte)$/.test(id)) { return null; } try { const staticAssetsRegex = /staticAssets\s*\(\s*['"]([^'"]+)['"]\s*\)/g; for (const match of code.matchAll(staticAssetsRegex)) { const assetPath = match[1]; if (!currentFiles.has(assetPath)) { const relativeId = normalizePath(path.relative(process.cwd(), id)); const relativeDir = normalizePath(path.relative(process.cwd(), directory)); throw new Error(`\n\n${chalk.red('Error:')} Static asset: ${chalk.yellowBright(assetPath)}\n Referenced in: ${chalk.cyan(relativeId)}\n Asset not found in scanned directory: ${chalk.blue(relativeDir)}\n\n Please ensure the asset exists and the path is correct.\n`); } } // Validate staticAssetsFromDir if enabled - Original Logic if (enableDirectoryTypes) { const staticAssetsDirRegex = /staticAssetsFromDir\s*\(\s*['"]([^'"]+)['"]\s*\)/g; for (const dirMatch of code.matchAll(staticAssetsDirRegex)) { const dirPath = dirMatch[1]; let hasAssetsInDir = false; if (dirPath === '.') { hasAssetsInDir = Array.from(currentFiles).some(file => !file.includes('/')); } else { const normalizedPath = path.posix.normalize(dirPath); // Use posix for check const dirPathWithSlash = normalizedPath.endsWith('/') ? normalizedPath : `${normalizedPath}/`; hasAssetsInDir = Array.from(currentFiles).some(file => file.startsWith(dirPathWithSlash)); } if (!hasAssetsInDir && options.allowEmptyDirectories !== true) { const relativeId = normalizePath(path.relative(process.cwd(), id)); const relativeDir = normalizePath(path.relative(process.cwd(), directory)); const message = `\n\n${chalk.red('Error:')} Static asset directory: ${chalk.yellowBright(dirPath)}\n Referenced in: ${chalk.cyan(relativeId)}\n Directory is empty or does not exist in scanned directory: ${chalk.blue(relativeDir)}\n\n Ensure the directory contains assets or set 'allowEmptyDirectories: true'.\n`; throw new Error(message); } } } } catch (err) { // Re-throw specific validation errors to show in Vite overlay if (err instanceof Error && (err.message.includes('Static asset:') || err.message.includes('Static asset directory:'))) { console.error(err.message); // Log clean message throw err; // Throw to Vite } // Log other unexpected errors but don't break the build const relativeId = normalizePath(path.relative(process.cwd(), id)); console.error(`${chalk.red('✗')} Unexpected error validating asset references in ${relativeId}: ${err instanceof Error ? err.message : err}`); } return null; // No code transformation needed }, // Cleanup watcher - Original Logic async buildEnd() { if (watcher) { console.log(`${chalk.cyan('ℹ')} [vite-plugin-static-assets] Closing file watcher...`); try { await watcher.close(); watcher = null; console.log(`${chalk.yellow('⚠')} File watcher closed.`); } catch (err) { console.error(`${chalk.red('✗')} Error closing file watcher: ${err instanceof Error ? err.message : err}`); } } if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } }, // Ensure watcher closes on dev server shutdown (Good practice) configureServer(server) { server.httpServer?.on('close', async () => { if (watcher) { await watcher.close(); watcher = null; console.log(`${chalk.yellow('⚠')} [vite-plugin-static-assets] File watcher closed on server shutdown.`); } if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } }); } }; }