2021-03-26 10:56:32 +01:00
|
|
|
import chokidar, {FSWatcher} from "chokidar";
|
2021-04-28 14:04:56 +02:00
|
|
|
import {Express} from "express";
|
2021-05-03 19:29:22 +02:00
|
|
|
import fs from "fs";
|
|
|
|
import path from "path";
|
|
|
|
|
|
|
|
import {logger} from "../Logger.js";
|
|
|
|
import {afs, readdirRecursively} from "../Utils.js";
|
2021-03-24 21:41:13 +01:00
|
|
|
|
|
|
|
export default abstract class ViewEngine {
|
2021-04-28 14:04:56 +02:00
|
|
|
private static readonly globals: Record<string, unknown> = {};
|
|
|
|
|
|
|
|
public static getGlobals(): Record<string, unknown> {
|
2021-04-28 14:53:46 +02:00
|
|
|
return {...this.globals};
|
2021-04-28 14:04:56 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
public static setGlobal(key: string, value: unknown): void {
|
|
|
|
this.globals[key] = value;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
2021-03-24 21:41:13 +01:00
|
|
|
protected readonly viewPaths: string[];
|
2021-03-26 10:56:32 +01:00
|
|
|
private watcher?: FSWatcher;
|
2021-03-24 21:41:13 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* @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 = [
|
2021-05-03 19:29:22 +02:00
|
|
|
...additionalViewPaths,
|
|
|
|
'views',
|
|
|
|
'node_modules/swaf/views',
|
|
|
|
].map(p => path.resolve(p))
|
|
|
|
.filter(dir => fs.existsSync(dir));
|
2021-03-24 21:41:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
public abstract getExtension(): string;
|
|
|
|
|
2021-04-28 14:07:11 +02:00
|
|
|
|
2021-03-24 21:41:13 +01:00
|
|
|
public abstract render(
|
|
|
|
file: string,
|
|
|
|
locals: Record<string, unknown>,
|
2021-04-28 14:53:46 +02:00
|
|
|
): Promise<string>;
|
2021-03-24 21:41:13 +01:00
|
|
|
|
2021-04-28 14:07:11 +02:00
|
|
|
public setup(app: Express, main: boolean): void {
|
2021-04-28 14:04:56 +02:00
|
|
|
app.engine(this.getExtension(), (path, options, callback) => {
|
|
|
|
// Props (locals)
|
2021-04-28 14:53:46 +02:00
|
|
|
const locals = Object.assign(options, ViewEngine.getGlobals());
|
2021-04-28 14:04:56 +02:00
|
|
|
|
2021-04-28 14:53:46 +02:00
|
|
|
this.render(path, locals)
|
|
|
|
.then(value => callback(null, value))
|
2021-04-28 14:04:56 +02:00
|
|
|
.catch(err => callback(err));
|
|
|
|
});
|
2021-03-24 21:41:13 +01:00
|
|
|
|
2021-04-28 14:07:11 +02:00
|
|
|
const existingViewPaths = app.get('views');
|
|
|
|
app.set('views', existingViewPaths ?
|
2021-04-28 14:53:46 +02:00
|
|
|
[...new Set([
|
|
|
|
...typeof existingViewPaths === 'string' ?
|
|
|
|
[existingViewPaths] :
|
|
|
|
existingViewPaths,
|
|
|
|
...this.getViewPaths(),
|
|
|
|
])] :
|
2021-04-28 14:07:11 +02:00
|
|
|
this.getViewPaths());
|
2021-03-24 21:41:13 +01:00
|
|
|
|
2021-04-28 14:07:11 +02:00
|
|
|
if (main) {
|
|
|
|
app.set('view engine', this.getExtension());
|
|
|
|
}
|
2021-03-24 21:41:13 +01:00
|
|
|
}
|
|
|
|
|
2021-04-28 14:07:11 +02:00
|
|
|
public getViewPaths(): string[] {
|
|
|
|
return this.viewPaths;
|
2021-03-24 21:41:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
public preCompile?(canonicalName: string, alsoCompileDependents: boolean): Promise<void>;
|
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
public afterPreCompile?(watch: boolean): Promise<void>;
|
2021-03-24 21:41:13 +01:00
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
public async preCompileAll(watch: boolean): Promise<void> {
|
2021-03-24 21:41:13 +01:00
|
|
|
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<string>(views)]) {
|
|
|
|
await this.preCompile(canonicalName, false);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
await this.afterPreCompile?.(watch);
|
|
|
|
|
|
|
|
if (watch) {
|
|
|
|
await this.watch();
|
|
|
|
}
|
2021-03-24 21:41:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
public onNewFile?(file: string): Promise<void>;
|
|
|
|
|
|
|
|
public onFileChange?(file: string): Promise<void>;
|
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
public onFileRemove?(file: string): Promise<void>;
|
|
|
|
|
2021-03-24 21:41:13 +01:00
|
|
|
public async watch(): Promise<void> {
|
2021-03-26 10:56:32 +01:00
|
|
|
this.watcher = chokidar.watch(this.devWatchedViewDir, {persistent: true});
|
|
|
|
this.watcher.on('ready', () => {
|
|
|
|
if (!this.watcher) return;
|
2021-03-24 21:41:13 +01:00
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
logger.info(`Watching ${this.getExtension()} assets for changes`);
|
|
|
|
|
|
|
|
this.watcher.on('add', (file) => {
|
2021-03-24 21:41:13 +01:00
|
|
|
if (file.endsWith('.' + this.getExtension())) {
|
|
|
|
(this.onNewFile ? this.onNewFile(file) : Promise.resolve())
|
|
|
|
.then(() => this.preCompile?.(this.toCanonicalName(file), true))
|
2021-03-26 10:56:32 +01:00
|
|
|
.then(() => this.afterPreCompile?.(true))
|
2021-03-24 21:41:13 +01:00
|
|
|
.catch(err => logger.error(err));
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
this.watcher.on('change', (file) => {
|
2021-03-24 21:41:13 +01:00
|
|
|
if (file.endsWith('.' + this.getExtension())) {
|
|
|
|
(this.onFileChange ? this.onFileChange(file) : Promise.resolve())
|
|
|
|
.then(() => this.preCompile?.(this.toCanonicalName(file), true))
|
2021-03-26 10:56:32 +01:00
|
|
|
.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())
|
2021-03-24 21:41:13 +01:00
|
|
|
.catch(err => logger.error(err));
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2021-03-26 10:56:32 +01:00
|
|
|
public async stop(): Promise<void> {
|
|
|
|
if (this.watcher) {
|
|
|
|
await this.watcher.close();
|
|
|
|
this.watcher = undefined;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-03-24 21:41:13 +01:00
|
|
|
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<string> {
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|