swaf/src/frontend/ViewEngine.ts

186 lines
6.2 KiB
TypeScript
Raw Normal View History

2021-03-24 21:41:13 +01:00
import path from "path";
import fs from "fs";
import chokidar, {FSWatcher} from "chokidar";
2021-03-24 21:41:13 +01:00
import {logger} from "../Logger";
import {afs, readdirRecursively} from "../Utils";
import {Express} from "express";
2021-03-24 21:41:13 +01:00
export default abstract class ViewEngine {
private static readonly globals: Record<string, unknown> = {};
public static getGlobals(): Record<string, unknown> {
return this.globals;
}
public static setGlobal(key: string, value: unknown): void {
this.globals[key] = value;
}
2021-03-24 21:41:13 +01:00
protected readonly viewPaths: string[];
private watcher?: FSWatcher;
2021-03-24 21:41:13 +01:00
/**
* @param buildDir A temporary directory that will contain any non-final or final non-public asset.
* @param publicDir The output directory that should contain all final and public assets.
* @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 buildDir: string,
private readonly publicDir: string,
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));
if (!fs.existsSync(this.buildDir)) {
fs.mkdirSync(this.buildDir, {recursive: true});
}
}
public abstract getExtension(): string;
public abstract render(
file: string,
locals: Record<string, unknown>,
callback: (err: Error | null, output?: string) => void,
): Promise<void>;
public setup(app: Express): void {
app.engine(this.getExtension(), (path, options, callback) => {
// Props (locals)
const locals = Object.assign(ViewEngine.getGlobals(), options);
this.render(path, locals, callback)
.catch(err => callback(err));
});
}
2021-03-24 21:41:13 +01:00
public getViewPaths(): string[] {
return this.viewPaths;
}
public getBuildDir(): string {
return this.buildDir;
}
public getPublicDir(): string {
return this.publicDir;
}
public getDevWatchedViewDir(): string {
return this.devWatchedViewDir;
}
public preCompile?(canonicalName: string, alsoCompileDependents: boolean): Promise<void>;
public afterPreCompile?(watch: boolean): Promise<void>;
2021-03-24 21:41:13 +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);
}
}
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>;
public onFileRemove?(file: string): Promise<void>;
2021-03-24 21:41:13 +01:00
public async watch(): Promise<void> {
this.watcher = chokidar.watch(this.devWatchedViewDir, {persistent: true});
this.watcher.on('ready', () => {
if (!this.watcher) return;
2021-03-24 21:41:13 +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))
.then(() => this.afterPreCompile?.(true))
2021-03-24 21:41:13 +01:00
.catch(err => logger.error(err));
}
});
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))
.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));
}
});
});
}
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);
}
}