Skip to content

Commit

Permalink
Support always-on plugins (#714)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jamos-tay authored and yamgent committed Mar 28, 2019
1 parent 0efb692 commit a2f88a2
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 41 deletions.
23 changes: 23 additions & 0 deletions docs/userGuide/usingPlugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,29 @@ This will add the following link and script elements to the page:
- `<script src="SCRIPT_LINK"></script>`
- `<script>alert("hello")</script>`

### 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.
Expand Down
44 changes: 17 additions & 27 deletions src/Page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<a class="fa fa-anchor" href="#"></a>';
const DROPDOWN_BUTTON_ICON_HTML = '<i class="dropdown-btn-icon">\n'
+ '<span class="glyphicon glyphicon-menu-down" aria-hidden="true"></span>\n'
+ '</i>';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
*/
Expand All @@ -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;
Expand All @@ -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;
Expand Down
77 changes: 65 additions & 12 deletions src/Site.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -37,13 +39,15 @@ 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';
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';
Expand Down Expand Up @@ -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
Expand All @@ -711,32 +716,80 @@ 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;
}

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));
};

/**
Expand Down
40 changes: 40 additions & 0 deletions src/plugins/default/markbind-plugin-anchors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const cheerio = module.parent.require('cheerio');
const md = require('./../../lib/markbind/src/lib/markdown-it');

const ANCHOR_HTML = '<a class="fa fa-anchor" href="#"></a>';

/**
* 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();
},
};
4 changes: 2 additions & 2 deletions test/functional/test_site/expected/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,8 @@ <h1 id="markbind-plugin-pre-render">Markbind Plugin Pre-render<a class="fa fa-an
<p>Node Modules Plugin Post-render</p>
</div>
<h1 id="test-search-indexing">Test search indexing<a class="fa fa-anchor" href="#test-search-indexing"></a></h1>
<h2 class="no-index" id="level-2-header-inside-headingsearchindex-with-no-index-attribute-should-not-be-indexed">Level 2 header (inside headingSearchIndex) with no-index attribute should not be indexed</h2>
<h6 class="always-index" id="level-6-header-outside-headingsearchindex-with-always-index-attribute-should-be-indexed">Level 6 header (outside headingSearchIndex) with always-index attribute should be indexed<a class="fa fa-anchor" href="#level-6-header-outside-headingsearchindex-with-always-index-attribute-should-be-indexed"></a></h6>
<h2 class="no-index" id="level-2-header-inside-headingsearchindex-with-no-index-attribute-should-not-be-indexed">Level 2 header (inside headingSearchIndex) with no-index attribute should not be indexed<a class="fa fa-anchor" href="#level-2-header-inside-headingsearchindex-with-no-index-attribute-should-not-be-indexed"></a></h2>
<h6 class="always-index" id="level-6-header-outside-headingsearchindex-with-always-index-attribute-should-be-indexed">Level 6 header (outside headingSearchIndex) with always-index attribute should be indexed</h6>
</div>
<nav id="page-nav" class="navbar navbar-light bg-transparent">
<div class="sticky-top spacer-top viewport-height-90 scrollable slim-scroll">
Expand Down

0 comments on commit a2f88a2

Please sign in to comment.