From a2f88a2207c6c94ad0ef954344f3f45773cef708 Mon Sep 17 00:00:00 2001 From: Jamos Tay Date: Fri, 29 Mar 2019 00:52:55 +0800 Subject: [PATCH] Support always-on plugins (#714) When authors want to use plugins for his website, he or she must modify site.json to enable them manually. There are no plugins that are enabled by default. In the future, there may be built-in MarkBind plugins that should be enabled by default. For example, we want to move the anchor functionality into a plugin. However, as there are no default plugins, MarkBind will have them disabled by default, even though anchors are a common feature in websites and it would be troublesome for authors to enable this manually. Let's add always-on plugins, plugins that are always enabled by default unless the author specify in site.json to disable them. As a "proof-of-concept", let's also refactor the anchor logic to an always-on plugin to demonstrate how always-on plugins can be utilized. --- docs/userGuide/usingPlugins.md | 23 ++++++ src/Page.js | 44 ++++------- src/Site.js | 77 ++++++++++++++++--- .../default/markbind-plugin-anchors.js | 40 ++++++++++ test/functional/test_site/expected/index.html | 4 +- 5 files changed, 147 insertions(+), 41 deletions(-) create mode 100644 src/plugins/default/markbind-plugin-anchors.js 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