vite-static-assets-plugin
Version:
Typesafe static assets with Vite
398 lines (389 loc) • 20.1 kB
JavaScript
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;
}
});
}
};
}