180 lines
6.2 KiB
TypeScript
180 lines
6.2 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";
|
||
|
|
||
|
export default abstract class AssetPreCompiler {
|
||
|
protected readonly assetPaths: string[];
|
||
|
private watcher?: FSWatcher;
|
||
|
private afterPreCompileHandlers: ((watch: boolean) => Promise<void>)[] = [];
|
||
|
private inputChangeHandler?: (restart: boolean) => Promise<void>;
|
||
|
|
||
|
/**
|
||
|
* @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,
|
||
|
'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 async stop(): Promise<void> {
|
||
|
if (this.watcher) {
|
||
|
await this.watcher.close();
|
||
|
this.watcher = undefined;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
public getViewPaths(): string[] {
|
||
|
return this.assetPaths;
|
||
|
}
|
||
|
|
||
|
public getPrimaryAssetPath(): string {
|
||
|
if (this.assetPaths.length === 0) throw new Error('No asset path was found.');
|
||
|
return this.assetPaths[0];
|
||
|
}
|
||
|
|
||
|
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> {
|
||
|
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
|
||
|
for (const canonicalName of [...new Set<string>(views)]) {
|
||
|
await this.preCompile(canonicalName, false);
|
||
|
}
|
||
|
|
||
|
await this.afterPreCompile(watch);
|
||
|
|
||
|
if (watch) {
|
||
|
await this.watch();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
public async watch(): Promise<void> {
|
||
|
this.watcher = chokidar.watch(this.getPrimaryAssetPath(), {persistent: true});
|
||
|
this.watcher.on('ready', () => {
|
||
|
if (!this.watcher) return;
|
||
|
|
||
|
logger.info(`Watching ${this.extension} assets for changes`);
|
||
|
|
||
|
this.watcher.on('add', (file) => {
|
||
|
if (file.endsWith('.' + this.extension)) {
|
||
|
this.onNewFile(file)
|
||
|
.then(() => this.preCompile(this.toCanonicalName(file), true))
|
||
|
.then(() => {
|
||
|
return this.afterPreCompile(true);
|
||
|
})
|
||
|
.catch(err => logger.error(err));
|
||
|
}
|
||
|
});
|
||
|
|
||
|
this.watcher.on('change', (file) => {
|
||
|
if (file.endsWith('.' + this.extension)) {
|
||
|
(this.onFileChange ? this.onFileChange(file) : Promise.resolve())
|
||
|
.then(() => this.preCompile(this.toCanonicalName(file), true))
|
||
|
.then(() => {
|
||
|
return this.afterPreCompile(true);
|
||
|
})
|
||
|
.catch(err => logger.error(err));
|
||
|
}
|
||
|
});
|
||
|
|
||
|
this.watcher.on('unlink', (file) => {
|
||
|
if (file.endsWith('.' + this.extension)) {
|
||
|
this.onFileRemove(file)
|
||
|
.catch(err => logger.error(err));
|
||
|
}
|
||
|
});
|
||
|
});
|
||
|
}
|
||
|
|
||
|
public async onNewFile(_file: string): Promise<void> {
|
||
|
await this.inputChangeHandler?.(false);
|
||
|
}
|
||
|
|
||
|
public onFileChange?(file: string): Promise<void>;
|
||
|
|
||
|
public async onFileRemove(_file: string): Promise<void> {
|
||
|
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> {
|
||
|
for (const viewPath of this.assetPaths) {
|
||
|
const tryPath = path.join(viewPath, canonicalName);
|
||
|
if (await doesFileExist(tryPath)) {
|
||
|
return tryPath;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
throw new Error('View not found from canonical name ' + canonicalName);
|
||
|
}
|
||
|
}
|