myst-cli
Version:
Command line tools for MyST
105 lines (104 loc) • 4.53 kB
JavaScript
import fs from 'node:fs';
import { pathToFileURL } from 'node:url';
import { RuleId, plural } from 'myst-common';
import { addWarningForFile } from './utils/addWarningForFile.js';
import { loadExecutablePlugin } from './executablePlugin.js';
/**
* Load user-defined plugin modules declared in the project frontmatter
*
* @param session session with logging
*/
export async function loadPlugins(session, plugins) {
const loadedPlugins = session.plugins ?? {
directives: [],
roles: [],
transforms: [],
paths: [],
};
// Deduplicate by path...
const newPlugins = [...new Map(plugins.map((info) => [info.path, info])).values()].filter(
// ...and filter out already loaded plugins
({ path }) => !loadedPlugins.paths.includes(path));
if (newPlugins.length === 0) {
return loadedPlugins;
}
session.log.debug(`Loading plugins: "${newPlugins.map((info) => `${info.path} (${info.type})`).join('", "')}"`);
const modules = await Promise.all(newPlugins.map(async (info) => {
const { type, path } = info;
switch (type) {
case 'executable': {
// Ensure the plugin is a file
if (!fs.statSync(path, { throwIfNoEntry: false })?.isFile()) {
addWarningForFile(session, path, `Unknown plugin "${path}", it must be an executable file`, 'error', {
ruleId: RuleId.pluginLoads,
});
return null;
}
// Ensure the plugin is executable
try {
fs.accessSync(path, fs.constants.X_OK);
}
catch (err) {
addWarningForFile(session, path, `Plugin "${path}" is not executable`, 'error', {
ruleId: RuleId.pluginLoads,
});
return null;
}
const plugin = await loadExecutablePlugin(session, info.path);
if (plugin === undefined) {
addWarningForFile(session, path, `Non-zero exit code after querying executable "${path}" for plugin specification`, 'error', {
ruleId: RuleId.pluginLoads,
});
return null;
}
return { path, module: { plugin } };
}
case 'javascript': {
if (!fs.statSync(path, { throwIfNoEntry: false })?.isFile() || !path.endsWith('.mjs')) {
addWarningForFile(session, path, `Unknown plugin "${path}", it must be an mjs file`, 'error', {
ruleId: RuleId.pluginLoads,
});
return null;
}
let module;
const pathURL = pathToFileURL(path);
try {
module = await import(pathURL.toString());
}
catch (error) {
session.log.debug(`\n\n${error?.stack}\n\n`);
addWarningForFile(session, path, `Error reading plugin: ${error}`, 'error', {
ruleId: RuleId.pluginLoads,
});
return null;
}
return { path, module };
}
}
}));
modules.forEach((pluginLoader) => {
if (!pluginLoader)
return;
const plugin = pluginLoader.module.default || pluginLoader.module.plugin;
const directives = plugin.directives || pluginLoader.module.directives;
const roles = plugin.roles || pluginLoader.module.roles;
const transforms = plugin.transforms || pluginLoader.module.transforms;
session.log.info(`🔌 ${plugin?.name ?? 'Unnamed Plugin'} (${pluginLoader.path}) loaded: ${plural('%s directive(s)', directives)}, ${plural('%s role(s)', roles)}, ${plural('%s transform(s)', transforms)}`);
if (directives) {
// TODO: validate each directive
loadedPlugins.directives.push(...directives);
}
if (roles) {
// TODO: validate each role
loadedPlugins.roles.push(...roles);
}
if (transforms) {
// TODO: validate each transform
loadedPlugins.transforms.push(...transforms);
}
loadedPlugins.paths.push(pluginLoader.path);
});
session.plugins = loadedPlugins;
session.log.debug('Plugins loaded');
return loadedPlugins;
}