@argdown/node
Version:
Async Argdown application for node.js
338 lines (324 loc) • 10.3 kB
text/typescript
"use strict";
import defaultsDeep from "lodash.defaultsdeep";
import isFunction from "lodash.isfunction";
import isString from "lodash.isstring";
import isEmpty from "lodash.isempty";
import isObject from "lodash.isobject";
import cloneDeep from "lodash.clonedeep";
import {
ArgdownApplication,
IArgdownRequest,
IArgdownResponse,
ArgdownPluginError
} from "@argdown/core";
import { isAsyncPlugin } from "./IAsyncArgdownPlugin";
import path from "path";
import chokidar from "chokidar";
import glob from "glob";
import { promisify } from "util";
import importFresh from "import-fresh";
import { readFile } from "fs";
const readFileAsync = promisify(readFile);
export class AsyncArgdownApplication extends ArgdownApplication {
async runAsync(
request: IArgdownRequest,
response?: IArgdownResponse
): Promise<IArgdownResponse> {
let processorsToRun: string[] = [];
this.logger.setLevel("error");
let resp: IArgdownResponse = response || {};
let req = request;
if (req) {
if (req.logLevel) {
this.logger.setLevel(req.logLevel);
}
if (req.process) {
if (Array.isArray(req.process)) {
processorsToRun = req.process;
} else if (
isString(req.process) &&
req.processes &&
req.processes[req.process]
) {
const processObj = req.processes[req.process];
req = defaultsDeep({}, processObj, req);
if (isString(req.process)) {
processorsToRun = this.defaultProcesses[req.process];
} else if (req.process && req.process.constructor === Array) {
processorsToRun = <string[]>req.process;
}
} else if (isString(req.process)) {
processorsToRun = this.defaultProcesses[req.process];
}
}
}
if (isEmpty(processorsToRun)) {
this.logger.log(
"error",
"[AsyncArgdownApplication]: No processors to run."
);
return resp;
}
const exceptions: Error[] = [];
resp.exceptions = exceptions;
for (let processorId of processorsToRun) {
let cancelProcessor = false;
let processor = this.processors[processorId];
if (!processor) {
this.logger.log(
"error",
"[AsyncArgdownApplication]: Processor not found: " + processorId
);
continue;
}
this.logger.log(
"verbose",
"[AsyncArgdownApplication]: Running processor: " + processorId
);
for (let plugin of processor.plugins) {
if (isFunction(plugin.prepare)) {
this.logger.log(
"verbose",
"[AsyncArgdownApplication]: Preparing plugin: " + plugin.name
);
try {
plugin.prepare(req, resp, this.logger);
} catch (e) {
if (req.throwExceptions) {
throw e;
} else if (e instanceof ArgdownPluginError) {
e.processor = processorId;
exceptions.push(e);
cancelProcessor = true;
this.logger.log("warning", `Processor ${processorId} canceled.`);
break;
}
}
}
}
if (cancelProcessor) {
break;
}
if (resp.ast && processor.walker) {
try {
processor.walker.walk(req, resp, this.logger);
} catch (e) {
if (req.throwExceptions) {
throw e;
} else if (e instanceof ArgdownPluginError) {
e.processor = processorId;
cancelProcessor = true;
exceptions.push(e);
this.logger.log(
"warning",
`[ArgdownApplication]: Processor ${processorId} canceled.`
);
break;
}
}
}
if (cancelProcessor) {
break;
}
for (let plugin of processor.plugins) {
this.logger.log(
"verbose",
"[AsyncArgdownApplication]: Running plugin: " + plugin.name
);
try {
if (isAsyncPlugin(plugin)) {
await plugin.runAsync(req, resp, this.logger);
} else if (isFunction(plugin.run)) {
plugin.run(req, resp, this.logger);
}
} catch (e) {
if (req.throwExceptions) {
throw e;
} else if (e instanceof ArgdownPluginError) {
e.processor = processorId;
cancelProcessor = true;
this.logger.log("warning", `Processor ${processorId} canceled.`);
exceptions.push(e);
break;
}
}
}
if (cancelProcessor) {
break;
}
}
if (req.logExceptions === undefined || req.logExceptions) {
for (let exception of exceptions) {
let msg = exception.stack || exception.message;
if (exception instanceof ArgdownPluginError) {
msg = `[${exception.processor}/${exception.plugin}]: ${msg}`;
}
this.logger.log("error", msg);
}
}
return resp;
}
load = async (
request: IArgdownRequest
): Promise<IArgdownResponse[] | undefined> => {
const processObj =
request.processes && isString(request.process)
? request.processes[request.process]
: undefined;
let req = request;
if (processObj) {
req = defaultsDeep({}, processObj, req);
}
let inputGlob = req.inputPath || "./*.argdown";
const ignoreFiles = req.ignore || [
"**/_*", // Exclude files starting with '_'.
"**/_*/**" // Exclude entire directories starting with '_'.
];
if (
req.logger &&
isFunction(req.logger.log) &&
isFunction(req.logger.setLevel)
) {
if (!this.defaultLogger) {
this.defaultLogger = this.logger;
}
this.logger = req.logger;
} else if (this.defaultLogger) {
this.logger = this.defaultLogger;
}
if (!req.rootPath) {
req.rootPath = process.cwd();
}
if (req.logLevel) {
this.logger.setLevel(req.logLevel);
}
if (req.plugins) {
for (let pluginData of req.plugins) {
if (isObject(pluginData.plugin) && isString(pluginData.processor)) {
this.addPlugin(pluginData.plugin, pluginData.processor);
}
}
}
if (req.input && !req.inputPath) {
await this.runAsync(cloneDeep(request));
return;
}
const $ = this;
let absoluteInputGlob = path.resolve(req.rootPath, inputGlob);
const loadOptions: chokidar.WatchOptions = {};
if (ignoreFiles) {
// error in WatchOptions type declaration: option is called "ignore", not "ignored":
(<any>loadOptions).ignore = ignoreFiles;
}
if (req.watch) {
const watcher = chokidar.watch(absoluteInputGlob, loadOptions);
const watcherRequest = cloneDeep(req);
watcherRequest.watch = false;
watcher
.on("add", path => {
this.logger.log("verbose", `File ${path} has been added.`);
watcherRequest.inputPath = path;
$.load(watcherRequest);
})
.on("change", path => {
this.logger.log("verbose", `File ${path} has been changed.`);
watcherRequest.inputPath = path;
$.load(watcherRequest);
})
.on("unlink", path => {
this.logger.log("verbose", `File ${path} has been removed.`);
});
} else {
let files: string[] = await new Promise<string[]>((resolve, reject) => {
glob(
absoluteInputGlob,
loadOptions,
(er: Error | null, files: string[]) => {
if (er) {
reject(er);
}
resolve(files);
}
);
});
const promises = [];
if (files.length == 0) {
throw new ArgdownPluginError(
"AsyncArgdownApplication.load",
"no-files-found",
`No Argdown files found at: '${absoluteInputGlob}'`
);
}
for (let file of files) {
const requestForFile = cloneDeep(request);
requestForFile.inputPath = file;
promises.push(this.runAsync(requestForFile));
}
// Remove plugins added by request
if (req.plugins) {
for (let pluginData of req.plugins) {
this.removePlugin(pluginData.plugin, pluginData.processor);
}
}
return await Promise.all(promises);
}
return;
};
loadConfig = async (filePath?: string): Promise<IArgdownRequest> => {
let config: IArgdownRequest = {};
filePath = filePath || "argdown.config.json"; // json is default because it can be loaded asynchronously
filePath = path.resolve(process.cwd(), filePath);
const extension = path.extname(filePath);
// We use non-blocking IO for JSON config files
if (extension === ".json") {
try {
const buffer = await readFileAsync(filePath, "utf8");
config = JSON.parse(buffer);
} catch (e) {
if (e instanceof Error) {
this.logger.log(
"verbose",
"[AsyncArgdownApplication]: No config found: " + e.toString()
);
}
}
} else if (extension === ".js") {
// For Js config files we have to use loadJSFile which is synchronous
try {
let jsModuleExports = loadJSFile(filePath);
if (jsModuleExports.config) {
config = jsModuleExports.config;
} else {
// let's try the default export
config = jsModuleExports;
}
} catch (e) {
if (e instanceof Error) {
this.logger.log(
"verbose",
"[AsyncArgdownApplication]: No config found: " + e.toString()
);
}
}
}
return config;
};
}
/**
* Taken from eslint: https://github.com/eslint/eslint/blob/master/lib/config/config-file.js
* Loads a JavaScript configuration from a file.
* @param {string} filePath The filename to load.
* @returns {Object} The configuration object from the file.
* @throws {Error} If the file cannot be read.
* @private
*/
const loadJSFile = (filePath: string) => {
try {
return importFresh(filePath);
} catch (e) {
if (e instanceof Error) {
e.message = `Cannot read file: ${filePath}\nError: ${e.message}`;
}
throw e;
}
};