diff --git a/docs/userGuide/usingPlugins.md b/docs/userGuide/usingPlugins.md index f748898160..efc7078510 100644 --- a/docs/userGuide/usingPlugins.md +++ b/docs/userGuide/usingPlugins.md @@ -146,6 +146,29 @@ This will add the following link and script elements to the page: - `` - `` +### Advanced: Default plugins + +MarkBind has a set of default plugins that it uses to carry out some of its features. These are enabled by default for every project and should be left alone. + +Default Plugin | Functionality +--- | --- +`anchors` | Attaches anchor links to the side of headings. + +Although not advised, you can disable these by passing `"off": true` in the `pluginsContext`. + +Disabling the `anchors` plugin: + +```js +{ + ... + "pluginsContext": { + "anchors": { + "off": true + } + } +} +``` + ### Built-in plugins MarkBind has a set of built-in plugins that can be used immediately without installation. diff --git a/src/Page.js b/src/Page.js index 2637f07719..894c516675 100644 --- a/src/Page.js +++ b/src/Page.js @@ -38,7 +38,6 @@ const SITE_NAV_ID = 'site-nav'; const SITE_NAV_LIST_CLASS = 'site-nav-list'; const TITLE_PREFIX_SEPARATOR = ' - '; -const ANCHOR_HTML = ''; const DROPDOWN_BUTTON_ICON_HTML = '\n' + '\n' + ''; @@ -444,29 +443,6 @@ Page.prototype.concatenateHeadingsAndKeywords = function () { }); }; -/** - * Adds anchor links to headings in the page - * @param content of the page - */ -Page.prototype.addAnchors = function (content) { - const $ = cheerio.load(content, { xmlMode: false }); - if (this.headingIndexingLevel > 0) { - const headingsSelector = generateHeadingSelector(this.headingIndexingLevel); - $(headingsSelector).each((i, heading) => { - $(heading).append(ANCHOR_HTML.replace('#', `#${$(heading).attr('id')}`)); - }); - $('panel[header]').each((i, panel) => { - const panelHeading = cheerio.load(md.render(panel.attribs.header), { xmlMode: false }); - if (panelHeading(headingsSelector).length >= 1) { - const headingId = $(panelHeading(headingsSelector)[0]).attr('id'); - const anchorIcon = ANCHOR_HTML.replace(/"/g, "'").replace('#', `#${headingId}`); - $(panel).attr('header', `${$(panel).attr('header')}${anchorIcon}`); - } - }); - } - return $.html(); -}; - /** * Records the dynamic or static included files into this.includedFiles * @param dependencies array of maps of the external dependency and where it is included @@ -817,7 +793,6 @@ Page.prototype.generate = function (builtFiles) { .then(result => markbinder.resolveBaseUrl(result, fileConfig)) .then(result => fs.outputFileAsync(this.tempPath, result)) .then(() => markbinder.renderFile(this.tempPath, fileConfig)) - .then(result => this.addAnchors(result)) .then(result => this.postRender(result)) .then(result => this.collectPluginsAssets(result)) .then((result) => { @@ -860,6 +835,19 @@ Page.prototype.generate = function (builtFiles) { }); }; +/** + * Retrieves page config for plugins + */ +Page.prototype.getPluginConfig = function () { + return { + headingIndexingLevel: this.headingIndexingLevel, + enableSearch: this.enableSearch, + searchable: this.searchable, + rootPath: this.rootPath, + sourcePath: this.sourcePath, + }; +}; + /** * Entry point for plugin pre-render */ @@ -868,7 +856,8 @@ Page.prototype.preRender = function (content) { Object.entries(this.plugins).forEach(([pluginName, plugin]) => { if (plugin.preRender) { preRenderedContent - = plugin.preRender(preRenderedContent, this.pluginsContext[pluginName] || {}, this.frontMatter); + = plugin.preRender(preRenderedContent, this.pluginsContext[pluginName] || {}, + this.frontMatter, this.getPluginConfig()); } }); return preRenderedContent; @@ -882,7 +871,8 @@ Page.prototype.postRender = function (content) { Object.entries(this.plugins).forEach(([pluginName, plugin]) => { if (plugin.postRender) { postRenderedContent - = plugin.postRender(postRenderedContent, this.pluginsContext[pluginName] || {}, this.frontMatter); + = plugin.postRender(postRenderedContent, this.pluginsContext[pluginName] || {}, + this.frontMatter, this.getPluginConfig()); } }); return postRenderedContent; diff --git a/src/Site.js b/src/Site.js index 68d6326c66..2684a00a5a 100644 --- a/src/Site.js +++ b/src/Site.js @@ -11,7 +11,9 @@ const walkSync = require('walk-sync'); const _ = {}; _.difference = require('lodash/difference'); +_.get = require('lodash/get'); _.has = require('lodash/has'); +_.includes = require('lodash/includes'); _.isBoolean = require('lodash/isBoolean'); _.isUndefined = require('lodash/isUndefined'); _.noop = require('lodash/noop'); @@ -37,6 +39,7 @@ const TEMPLATE_ROOT_FOLDER_NAME = 'template'; const TEMPLATE_SITE_ASSET_FOLDER_NAME = 'markbind'; const BUILT_IN_PLUGIN_FOLDER_NAME = 'plugins'; +const BUILT_IN_DEFAULT_PLUGIN_FOLDER_NAME = 'plugins/default'; const FAVICON_DEFAULT_PATH = 'favicon.ico'; const FONT_AWESOME_PATH = 'asset/font-awesome.csv'; const FOOTER_PATH = '_markbind/footers/footer.md'; @@ -44,6 +47,7 @@ const HEADER_PATH = '_markbind/headers/header.md'; const GLYPHICONS_PATH = 'asset/glyphicons.csv'; const HEAD_FOLDER_PATH = '_markbind/head'; const INDEX_MARKDOWN_FILE = 'index.md'; +const MARKBIND_PLUGIN_PREFIX = 'markbind-plugin-'; const PAGE_TEMPLATE_NAME = 'page.ejs'; const PROJECT_PLUGIN_FOLDER_NAME = '_markbind/plugins'; const SITE_CONFIG_NAME = 'site.json'; @@ -701,7 +705,8 @@ Site.prototype.buildAssets = function () { /** * Retrieves the correct plugin path for a plugin name, if not in node_modules - * @param pluginName name of the plugin + * @param rootPath root of the project + * @param plugin name of the plugin */ function getPluginPath(rootPath, plugin) { // Check in project folder @@ -711,7 +716,13 @@ function getPluginPath(rootPath, plugin) { } // Check in src folder - const defaultPath = path.join(__dirname, BUILT_IN_PLUGIN_FOLDER_NAME, `${plugin}.js`); + const srcPath = path.join(__dirname, BUILT_IN_PLUGIN_FOLDER_NAME, `${plugin}.js`); + if (fs.existsSync(srcPath)) { + return srcPath; + } + + // Check in default folder + const defaultPath = path.join(__dirname, BUILT_IN_DEFAULT_PLUGIN_FOLDER_NAME, `${plugin}.js`); if (fs.existsSync(defaultPath)) { return defaultPath; } @@ -719,24 +730,66 @@ function getPluginPath(rootPath, plugin) { return ''; } +/** + * Finds plugins in the site's default plugin folder + */ +function findDefaultPlugins() { + const globPath = path.join(__dirname, BUILT_IN_DEFAULT_PLUGIN_FOLDER_NAME); + if (!fs.existsSync(globPath)) { + return []; + } + return walkSync(globPath, { + directories: false, + globs: [`${MARKBIND_PLUGIN_PREFIX}*.js`], + }).map(file => path.parse(file).name); +} + +/** + * Loads a plugin + * @param plugin name of the plugin + * @param isDefault whether the plugin is a default plugin + */ +Site.prototype.loadPlugin = function (plugin, isDefault) { + try { + // Check if already loaded + if (this.plugins[plugin]) { + return; + } + + const pluginPath = getPluginPath(this.rootPath, plugin); + if (isDefault && !pluginPath.startsWith(path.join(__dirname, BUILT_IN_DEFAULT_PLUGIN_FOLDER_NAME))) { + logger.warn(`Default plugin ${plugin} will be overridden`); + } + + // eslint-disable-next-line global-require, import/no-dynamic-require + this.plugins[plugin] = require(pluginPath || plugin); + } catch (e) { + logger.warn(`Unable to load plugin ${plugin}, skipping`); + } +}; + /** * Load all plugins of the site */ Site.prototype.collectPlugins = function () { if (!this.siteConfig.plugins) { - return; + this.siteConfig.plugins = []; } + module.paths.push(path.join(this.rootPath, 'node_modules')); - this.siteConfig.plugins.forEach((plugin) => { - try { - const pluginPath = getPluginPath(this.rootPath, plugin); - // eslint-disable-next-line global-require, import/no-dynamic-require - this.plugins[plugin] = require(pluginPath || plugin); - } catch (e) { - logger.warn(`Unable to load plugin ${plugin}, skipping`); - } - }); + const defaultPlugins = findDefaultPlugins(); + + this.siteConfig.plugins + .filter(plugin => !_.includes(defaultPlugins, plugin)) + .forEach(plugin => this.loadPlugin(plugin, false)); + + const markbindPrefixRegex = new RegExp(`^${MARKBIND_PLUGIN_PREFIX}`); + defaultPlugins + .filter(plugin => !_.get(this.siteConfig, + ['pluginsContext', plugin.replace(markbindPrefixRegex, ''), 'off'], + false)) + .forEach(plugin => this.loadPlugin(plugin, true)); }; /** diff --git a/src/plugins/default/markbind-plugin-anchors.js b/src/plugins/default/markbind-plugin-anchors.js new file mode 100644 index 0000000000..677feb8b83 --- /dev/null +++ b/src/plugins/default/markbind-plugin-anchors.js @@ -0,0 +1,40 @@ +const cheerio = module.parent.require('cheerio'); +const md = require('./../../lib/markbind/src/lib/markdown-it'); + +const ANCHOR_HTML = ''; + +/** + * Generates a heading selector based on the indexing level + * @param headingIndexingLevel to generate + */ +function generateHeadingSelector(headingIndexingLevel) { + let headingsSelector = 'h1'; + for (let i = 2; i <= headingIndexingLevel; i += 1) { + headingsSelector += `, h${i}`; + } + return headingsSelector; +} + +/** + * Adds anchor links to headers + */ +module.exports = { + postRender: (content, pluginContext, frontMatter, pageConfig) => { + const $ = cheerio.load(content, { xmlMode: false }); + if (pageConfig.headingIndexingLevel > 0) { + const headingsSelector = generateHeadingSelector(pageConfig.headingIndexingLevel); + $(headingsSelector).each((i, heading) => { + $(heading).append(ANCHOR_HTML.replace('#', `#${$(heading).attr('id')}`)); + }); + $('panel[header]').each((i, panel) => { + const panelHeading = cheerio.load(md.render(panel.attribs.header), { xmlMode: false }); + if (panelHeading(headingsSelector).length >= 1) { + const headingId = $(panelHeading(headingsSelector)[0]).attr('id'); + const anchorIcon = ANCHOR_HTML.replace(/"/g, "'").replace('#', `#${headingId}`); + $(panel).attr('header', `${$(panel).attr('header')}${anchorIcon}`); + } + }); + } + return $.html(); + }, +}; diff --git a/test/functional/test_site/expected/index.html b/test/functional/test_site/expected/index.html index 4d53635a64..087a78785e 100644 --- a/test/functional/test_site/expected/index.html +++ b/test/functional/test_site/expected/index.html @@ -437,8 +437,8 @@

Markbind Plugin Pre-renderTest search indexing

-

Level 2 header (inside headingSearchIndex) with no-index attribute should not be indexed

-
Level 6 header (outside headingSearchIndex) with always-index attribute should be indexed
+

Level 2 header (inside headingSearchIndex) with no-index attribute should not be indexed

+
Level 6 header (outside headingSearchIndex) with always-index attribute should be indexed