swaf/src/frontend/AssetPreCompiler.ts

216 lines
7.3 KiB
TypeScript

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<void>)[] = [];
private inputChangeHandler?: (restart: boolean) => Promise<void>;
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<void> {
if (this.watcher) {
await this.watcher.close();
this.watcher = undefined;
}
}
public abstract preCompile(canonicalName: string, alsoCompileDependents: boolean): Promise<void>;
public onPreCompile(afterPreCompileHandler: (watch: boolean) => Promise<void>): void {
this.afterPreCompileHandlers.push(afterPreCompileHandler);
}
protected async afterPreCompile(watch: boolean): Promise<void> {
await Promise.all(this.afterPreCompileHandlers.map(handler => handler(watch)));
}
public async preCompileAll(watch: boolean): Promise<void> {
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<string>(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<void> {
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<void> {
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<void> {
logger.silly('c', file);
const canonicalName = this.toCanonicalName(file);
await this.preCompile(canonicalName, true);
await this.afterPreCompile(true);
}
public async onFileRemove(file: string): Promise<void> {
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>): 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<string | null> {
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<string> {
const file = await this.resolveFileFromCanonicalName(canonicalName);
if (file) return file;
else throw new Error('View not found from canonical name ' + canonicalName);
}
}