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"; import Globals from "./Globals.js"; export default abstract class AssetPreCompiler { protected readonly assetPaths: string[]; private globals?: Globals; private watcher?: FSWatcher; private afterPreCompileHandlers: ((watch: boolean) => Promise)[] = []; private inputChangeHandler?: (restart: boolean) => Promise; private hadError: boolean = false; /** * @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, 'src/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 getViewPaths(): string[] { return this.assetPaths; } protected getGlobals(): Globals { if (!this.globals) throw new Error('globals field not intialized.'); return this.globals; } public setGlobals(globals: Globals): void { this.globals = globals; } public async stop(): Promise { if (this.watcher) { await this.watcher.close(); this.watcher = undefined; } } 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 { if (watch) { await this.watch(); } 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 const hasInputChanged = this.hadError; this.hadError = false; for (const canonicalName of [...new Set(views)]) { try { await this.preCompile(canonicalName, false); } catch (e) { logger.error(e); this.hadError = true; } } // If previous preCompileAll had errors, not all output files were generated. if (hasInputChanged) { await this.inputChangeHandler?.(false); } await this.afterPreCompile(watch); if (this.hadError && !watch) throw new Error('Errors while precompiling assets.'); } public async watch(): Promise { const watchedPaths = this.assetPaths.map(p => `${p}/**/*.${this.getExtension()}`); this.watcher = chokidar.watch(watchedPaths, {persistent: true}); this.watcher.on('ready', () => { if (!this.watcher) return; logger.info(`Watching ${this.extension} assets for changes in ${watchedPaths}`); this.watcher.on('add', (file) => { this.onNewFile(file) .catch(err => logger.error(err)); }); this.watcher.on('change', (file) => { this.onFileChange(file) .catch(err => logger.error(err)); }); this.watcher.on('unlink', (file) => { this.onFileRemove(file) .catch(err => logger.error(err)); }); }); } public async onNewFile(file: string): Promise { logger.silly('a', file); const canonicalName = this.toCanonicalName(file); await this.preCompile(canonicalName, true); await this.afterPreCompile(true); await this.inputChangeHandler?.(false); } public async onFileChange(file: string): Promise { logger.silly('c', file); const canonicalName = this.toCanonicalName(file); await this.preCompile(canonicalName, true); await this.afterPreCompile(true); } public async onFileRemove(file: string): Promise { logger.silly('r', file); const canonicalName = this.toCanonicalName(file); const replacementSourceFile = await this.resolveFileFromCanonicalName(canonicalName); if (replacementSourceFile) { await this.preCompile(canonicalName, true); await this.afterPreCompile(true); } else { const targetFile = path.resolve(this.targetDir, canonicalName); await fs.rm(targetFile); } 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; } } return null; } protected async resolveFileFromCanonicalNameOrFail(canonicalName: string): Promise { const file = await this.resolveFileFromCanonicalName(canonicalName); if (file) return file; else throw new Error('View not found from canonical name ' + canonicalName); } }