import path from "path"; import fs from "fs"; import chokidar, {FSWatcher} from "chokidar"; import {logger} from "../Logger"; import {afs, readdirRecursively} from "../Utils"; import {Express} from "express"; export default abstract class ViewEngine { private static readonly globals: Record = {}; public static getGlobals(): Record { return {...this.globals}; } public static setGlobal(key: string, value: unknown): void { this.globals[key] = value; } protected readonly viewPaths: string[]; private watcher?: FSWatcher; /** * @param devWatchedViewDir The directory that should be watched in dev environment. * @param additionalViewPaths By order of priority, the directories that contain all the views of the app. * Swaf provided views and default ./view directory are automatically added (don't add them yourself). * @protected */ protected constructor( private readonly devWatchedViewDir: string, ...additionalViewPaths: string[] ) { this.viewPaths = [ ...additionalViewPaths.map(p => path.resolve(p)), path.resolve(__dirname, '../../../views'), path.resolve(__dirname, '../../views'), path.resolve(__dirname, '../views'), ].filter(dir => fs.existsSync(dir)); } public abstract getExtension(): string; public abstract render( file: string, locals: Record, ): Promise; public setup(app: Express, main: boolean): void { app.engine(this.getExtension(), (path, options, callback) => { // Props (locals) const locals = Object.assign(options, ViewEngine.getGlobals()); this.render(path, locals) .then(value => callback(null, value)) .catch(err => callback(err)); }); const existingViewPaths = app.get('views'); app.set('views', existingViewPaths ? [...new Set([ ...typeof existingViewPaths === 'string' ? [existingViewPaths] : existingViewPaths, ...this.getViewPaths(), ])] : this.getViewPaths()); if (main) { app.set('view engine', this.getExtension()); } } public getViewPaths(): string[] { return this.viewPaths; } public preCompile?(canonicalName: string, alsoCompileDependents: boolean): Promise; public afterPreCompile?(watch: boolean): Promise; public async preCompileAll(watch: boolean): Promise { if (this.preCompile) { logger.info(`Pre-compiling ${this.getExtension()} views...`); // List all views const views: string[] = []; for (const viewPath of this.viewPaths) { await readdirRecursively(viewPath, async file => { if (file.endsWith('.' + this.getExtension())) { 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 onNewFile?(file: string): Promise; public onFileChange?(file: string): Promise; public onFileRemove?(file: string): Promise; public async watch(): Promise { this.watcher = chokidar.watch(this.devWatchedViewDir, {persistent: true}); this.watcher.on('ready', () => { if (!this.watcher) return; logger.info(`Watching ${this.getExtension()} assets for changes`); this.watcher.on('add', (file) => { if (file.endsWith('.' + this.getExtension())) { (this.onNewFile ? this.onNewFile(file) : Promise.resolve()) .then(() => this.preCompile?.(this.toCanonicalName(file), true)) .then(() => this.afterPreCompile?.(true)) .catch(err => logger.error(err)); } }); this.watcher.on('change', (file) => { if (file.endsWith('.' + this.getExtension())) { (this.onFileChange ? this.onFileChange(file) : Promise.resolve()) .then(() => this.preCompile?.(this.toCanonicalName(file), true)) .then(() => this.afterPreCompile?.(true)) .catch(err => logger.error(err)); } }); this.watcher.on('unlink', (file) => { if (file.endsWith('.' + this.getExtension())) { (this.onFileRemove ? this.onFileRemove(file) : Promise.resolve()) .catch(err => logger.error(err)); } }); }); } public async stop(): Promise { if (this.watcher) { await this.watcher.close(); this.watcher = undefined; } } protected toCanonicalName(file: string): string { const resolvedFilePath = path.resolve(file); let canonicalViewName: string | null = null; for (const viewPath of this.viewPaths) { 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.viewPaths) { const tryPath = path.join(viewPath, canonicalName); if (await afs.exists(tryPath)) { return tryPath; } } throw new Error('View not found from canonical name ' + canonicalName); } }