import chokidar, {FSWatcher} from "chokidar"; import {existsSync, mkdirSync,promises as fs} from "fs"; import path from "path"; import {logger} from "../Logger.js"; import {doesFileExist, listFilesRecursively} from "../Utils.js"; export default abstract class AssetPreCompiler { protected readonly assetPaths: string[]; private watcher?: FSWatcher; private afterPreCompileHandlers: ((watch: boolean) => Promise)[] = []; private inputChangeHandler?: (restart: boolean) => Promise; /** * @param targetDir The directory to put pre-compiled assets into. * @param assetType This must be the assets sub-directory name of the asset type this pre-compiler will handle. * Example: ts * Example: views * @param extension The asset files extension. * @param outputToPublicDir should the assets be compiled into the publicly served directory? * @param additionalFallbackAssetPaths By order of priority, fallback asset directories. * Swaf provided assets and default ./assets directory are automatically added (don't add them yourself). */ protected constructor( protected readonly targetDir: string, protected readonly assetType: string, protected readonly extension: string, private readonly outputToPublicDir: boolean, ...additionalFallbackAssetPaths: string[] ) { this.assetPaths = [ ...additionalFallbackAssetPaths, 'assets', 'node_modules/swaf/assets', ].map(p => path.resolve(p, assetType)) .filter(dir => existsSync(dir)); this.targetDir = path.join(targetDir, assetType); if (!existsSync(this.targetDir)) { mkdirSync(this.targetDir, {recursive: true}); } } public getExtension(): string { return this.extension; } public isPublic(): boolean { return this.outputToPublicDir; } public async stop(): Promise { if (this.watcher) { await this.watcher.close(); this.watcher = undefined; } } public getViewPaths(): string[] { return this.assetPaths; } public getPrimaryAssetPath(): string { if (this.assetPaths.length === 0) throw new Error('No asset path was found.'); return this.assetPaths[0]; } public abstract preCompile(canonicalName: string, alsoCompileDependents: boolean): Promise; public onPreCompile(afterPreCompileHandler: (watch: boolean) => Promise): void { this.afterPreCompileHandlers.push(afterPreCompileHandler); } protected async afterPreCompile(watch: boolean): Promise { await Promise.all(this.afterPreCompileHandlers.map(handler => handler(watch))); } public async preCompileAll(watch: boolean): Promise { logger.info(`Pre-compiling ${this.extension} views...`); // List all views const views: string[] = []; for (const viewPath of this.assetPaths) { for (const file of await listFilesRecursively(viewPath)) { if (file.endsWith('.' + this.extension)) { views.push(this.toCanonicalName(file)); } } } // Deduplicate and pre-compile for (const canonicalName of [...new Set(views)]) { await this.preCompile(canonicalName, false); } await this.afterPreCompile(watch); if (watch) { await this.watch(); } } public async watch(): Promise { this.watcher = chokidar.watch(this.getPrimaryAssetPath(), {persistent: true}); this.watcher.on('ready', () => { if (!this.watcher) return; logger.info(`Watching ${this.extension} assets for changes`); this.watcher.on('add', (file) => { if (file.endsWith('.' + this.extension)) { this.onNewFile(file) .then(() => this.preCompile(this.toCanonicalName(file), true)) .then(() => { return this.afterPreCompile(true); }) .catch(err => logger.error(err)); } }); this.watcher.on('change', (file) => { if (file.endsWith('.' + this.extension)) { (this.onFileChange ? this.onFileChange(file) : Promise.resolve()) .then(() => this.preCompile(this.toCanonicalName(file), true)) .then(() => { return this.afterPreCompile(true); }) .catch(err => logger.error(err)); } }); this.watcher.on('unlink', (file) => { if (file.endsWith('.' + this.extension)) { this.onFileRemove(file) .catch(err => logger.error(err)); } }); }); } public async onNewFile(_file: string): Promise { await this.inputChangeHandler?.(false); } public onFileChange?(file: string): Promise; public async onFileRemove(_file: string): Promise { await this.inputChangeHandler?.(true); } public onInputChange(inputChangeHandler: (restart: boolean) => Promise): void { this.inputChangeHandler = inputChangeHandler; } protected toCanonicalName(file: string): string { const resolvedFilePath = path.resolve(file); let canonicalViewName: string | null = null; for (const viewPath of this.assetPaths) { if (resolvedFilePath.startsWith(viewPath)) { canonicalViewName = resolvedFilePath.substring(viewPath.length + 1); } } if (!canonicalViewName) throw new Error('view ' + file + ' not found'); return canonicalViewName; } protected async resolveFileFromCanonicalName(canonicalName: string): Promise { for (const viewPath of this.assetPaths) { const tryPath = path.join(viewPath, canonicalName); if (await doesFileExist(tryPath)) { return tryPath; } } throw new Error('View not found from canonical name ' + canonicalName); } }