All files / src/plugins PluginManager.ts

81.57% Statements 62/76
41.66% Branches 5/12
92% Functions 23/25
80.82% Lines 59/73

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228                                11x 11x 11x   11x 11x 11x 11x                       6x   6x         6x         6x     6x   6x       6x 6x             6x     24x   6x       6x 6x 24x   24x                 24x   24x         24x 24x         24x                                     24x   24x 72x         72x             24x 24x         24x 24x 24x                                           6x   6x 24x 24x 12x     12x 12x 12x     12x     6x   6x                   40x             9x 36x     36x   36x       16x 64x       56x 224x         56x 224x           11x  
import _ from 'lodash';
 
import path from 'path';
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import fs from 'fs-extra';
import walkSync from 'walk-sync';
import * as logger from '../utils/logger.js';
import {
  FrontMatter, Plugin, PluginContext, TagConfigs,
} from './Plugin.js';
import type { NodeProcessorConfig } from '../html/NodeProcessor.js';
import type { PageAssets } from '../Page/PageConfig.js';
import { NodeOrText } from '../utils/node.js';
import { ignoreTags } from '../patches/index.js';
 
const require = createRequire(import.meta.url);
const __filepath = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filepath);
 
const MARKBIND_PLUGIN_DIRECTORY = __dirname;
const MARKBIND_DEFAULT_PLUGIN_DIRECTORY = path.join(__dirname, 'default');
const MARKBIND_PLUGIN_PREFIX = 'markbind-plugin-';
const PROJECT_PLUGIN_FOLDER_NAME = '_markbind/plugins';
 
export class PluginManager {
  static tagConfig: Record<string, TagConfigs>;
 
  config: NodeProcessorConfig;
  plugins: Record<string, Plugin>;
  pluginsRaw: string[];
  pluginsContextRaw: PluginContext;
  htmlBeautifyOptions: Record<string, any>;
 
  constructor(config: NodeProcessorConfig, plugins: string[], pluginsContext: PluginContext) {
    this.config = config;
 
    this.plugins = {};
 
    /**
     * Raw array of plugin names as read from the site configuration
     */
    this.pluginsRaw = plugins;
 
    /**
     * Raw representation of the site configuration's plugisnContext key
     */
    this.pluginsContextRaw = pluginsContext;
 
    // Plugin special tags may modify this
    this.htmlBeautifyOptions = {};
 
    this._setup();
  }
 
  _setup() {
    this._collectPlugins();
    this._collectPluginTagConfigs();
  }
 
  /**
   * Load all plugins of the site
   */
  _collectPlugins() {
    const defaultPluginNames = walkSync(MARKBIND_DEFAULT_PLUGIN_DIRECTORY, {
      directories: false,
      globs: [`${MARKBIND_PLUGIN_PREFIX}*.js`],
    }).map(file => path.basename(file, '.js'));
 
    this.pluginsRaw
      .filter(plugin => !_.includes(defaultPluginNames, plugin))
      .forEach(plugin => this._loadPlugin(plugin, false));
 
    const markbindPrefixRegex = new RegExp(`^${MARKBIND_PLUGIN_PREFIX}`);
    defaultPluginNames
      .filter(plugin => !_.get(this.pluginsContextRaw, `${plugin.replace(markbindPrefixRegex, '')}.off`,
                               false))
      .forEach(plugin => this._loadPlugin(plugin, true));
  }
 
  /**
   * Loads a plugin
   * @param plugin name of the plugin
   * @param isDefault whether the plugin is a default plugin
   */
  _loadPlugin(plugin: string, isDefault: boolean) {
    try {
      // Check if already loaded
      Iif (this.plugins[plugin]) {
        logger.warn(`Attempted to reload ${plugin} plugin. Is there a naming conflict?`);
        return;
      }
 
      const pluginPath = PluginManager._getPluginPath(this.config.rootPath, plugin);
      Iif (isDefault && !pluginPath.startsWith(MARKBIND_DEFAULT_PLUGIN_DIRECTORY)) {
        // Users can override default plugins with their own in the project folder
        logger.warn(`Default plugin ${plugin} will be overridden`);
      }
 
      this.plugins[plugin] = new Plugin(plugin, pluginPath, this.pluginsContextRaw[plugin],
                                        this.config.outputPath);
    } catch (e) {
      logger.warn(`Unable to load plugin ${plugin}, skipping...\n${e}`);
    }
  }
 
  /**
   * Retrieves the correct plugin path for a plugin name that exists either in (in decreasing priority):
   * - the MarkBind project's 'plugins' folder
   * - the current folder (__dirname)
   * - the 'default' subdirectory under the current folder
   * - one of the environment's valid node_modules folders, as loaded by node's require(...) method
   * @param projectRootPath root of the MarkBind project
   * @param pluginName name of the plugin
   */
  static _getPluginPath(projectRootPath: string, pluginName: string) {
    // Check in project folder for custom plugins
    // .cjs/.mjs are valid JavaScript extensions too, so check them
    const possibleExts = ['.js', '.cjs', '.mjs'];
    // eslint-disable-next-line no-restricted-syntax
    for (const ext of possibleExts) {
      const possiblePluginPath = path.join(
        projectRootPath,
        PROJECT_PLUGIN_FOLDER_NAME,
        `${pluginName}${ext}`,
      );
      Iif (fs.existsSync(possiblePluginPath)) {
        return possiblePluginPath;
      }
    }
 
    // Check in current (__dirname) folder
    // MarkBind plugins all have the .js extension - so we don't need to check for .cjs/.mjs files
    const markbindPluginPath = path.join(MARKBIND_PLUGIN_DIRECTORY, `${pluginName}.js`);
    Iif (fs.existsSync(markbindPluginPath)) {
      return markbindPluginPath;
    }
 
    // Check in default folder
    const markbindDefaultPluginPath = path.join(MARKBIND_DEFAULT_PLUGIN_DIRECTORY, `${pluginName}.js`);
    if (fs.existsSync(markbindDefaultPluginPath)) {
      return markbindDefaultPluginPath;
    }
 
    // Check the environment's node_modules folders
    try {
      const resolvedPluginPath = require.resolve(pluginName);
      return resolvedPluginPath;
    } catch (err) {
      // An error may be thrown because the module is not found, or for other reasons.
      // If the error is due to MODULE_NOT_FOUND, search project's node_modules
      Iif (_.isError(err) && (err as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND') {
        return require.resolve(pluginName, { paths: [path.join(projectRootPath, 'node_modules')] });
      }
      // Re-throw all other errors
      throw err;
    }
  }
 
  /**
   * Collects the tag configuration of the site's plugins, and injects them into the parsers.
   */
  _collectPluginTagConfigs() {
    const specialTags = new Set<string>(); // "non-html containing" tags parsed like <script>, <style>
 
    Object.values(this.plugins).forEach((plugin) => {
      const pluginTagConfig = plugin.getTagConfig();
      if (!pluginTagConfig) {
        return;
      }
 
      Object.entries(pluginTagConfig).forEach(([tagName, tagConfig]: [string, TagConfigs]) => {
        if (tagConfig.isSpecial) {
          specialTags.add(tagName.toLowerCase());
        }
      });
      _.merge(PluginManager.tagConfig, pluginTagConfig);
    });
 
    ignoreTags(specialTags);
 
    this.htmlBeautifyOptions = {
      indent_size: 2,
      content_unformatted: ['pre', 'textarea', 'script', ...specialTags],
    };
  }
 
  /**
   * Run the beforeSiteGenerate hooks
   */
  beforeSiteGenerate() {
    Object.values(this.plugins).forEach(plugin => plugin.executeBeforeSiteGenerate());
  }
 
  /**
   * Run getLinks and getScripts hooks
   */
  collectPluginPageNjkAssets(frontmatter: FrontMatter, content: string, pageAsset: PageAssets) {
    const pluginLinksAndScripts = Object.values(this.plugins)
      .map(plugin => plugin.getPageNjkLinksAndScripts(frontmatter, content, this.config.baseUrl));
 
    // eslint-disable-next-line lodash/prop-shorthand
    pageAsset.pluginLinks = _.flatMap(pluginLinksAndScripts, pluginResult => pluginResult.links);
    // eslint-disable-next-line lodash/prop-shorthand
    pageAsset.pluginScripts = _.flatMap(pluginLinksAndScripts, pluginResult => pluginResult.scripts);
  }
 
  postRender(frontmatter: FrontMatter, content: string) {
    return Object.values(this.plugins)
      .reduce((renderedContent, plugin) => plugin.postRender(frontmatter, renderedContent), content);
  }
 
  processNode(node: NodeOrText) {
    Object.values(this.plugins).forEach((plugin) => {
      plugin.processNode(node, this.config);
    });
  }
 
  postProcessNode(node: NodeOrText) {
    Object.values(this.plugins).forEach((plugin) => {
      plugin.postProcessNode(node, this.config);
    });
  }
}
 
// Static property for easy access in linkProcessor
PluginManager.tagConfig = {};