diff --git a/docs/_markbind/navigation/userGuideSections.md b/docs/_markbind/navigation/userGuideSections.md
index f0f1415be0..9f805e0c78 100644
--- a/docs/_markbind/navigation/userGuideSections.md
+++ b/docs/_markbind/navigation/userGuideSections.md
@@ -9,6 +9,7 @@
* [Formatting Contents]({{baseUrl}}/userGuide/formattingContents.html)
* [Using Components]({{baseUrl}}/userGuide/usingComponents.html)
* [Using HTML, JavaScript, CSS]({{baseUrl}}/userGuide/usingHtmlJavaScriptCss.html)
+ * [Using Plugins]({{baseUrl}}/userGuide/usingPlugins.html)
* [Tweaking the Page Structure]({{baseUrl}}/userGuide/tweakingThePageStructure.html)
* [Reusing Contents]({{baseUrl}}/userGuide/reusingContents.html)
* [Making the Site Searchable]({{baseUrl}}/userGuide/makingTheSiteSearchable.html)
diff --git a/docs/images/rendering.png b/docs/images/rendering.png
new file mode 100644
index 0000000000..33a9e08165
Binary files /dev/null and b/docs/images/rendering.png differ
diff --git a/docs/userGuide/usingPlugins.md b/docs/userGuide/usingPlugins.md
new file mode 100644
index 0000000000..dc57ef3c25
--- /dev/null
+++ b/docs/userGuide/usingPlugins.md
@@ -0,0 +1,182 @@
+
+ footer: userGuideFooter.md
+ siteNav: userGuideSections.md
+
+
+
+
+
+
+# Using Plugins
+
+A plugin is a user-defined extension that can add custom features to MarkBind. MarkBind plugins are `js` scripts that are loaded and run during the page generation. MarkBind allows plugins to modify a page's content during the page generation process.
+
+
+
+**WARNING:** Plugins are executable programs that can be written by anyone. This means that they might contain malicious code that may damage your computer.
+
+Only run plugins from sources that you trust. Do not run the plugin if the source/origin of the plugin cannot be ascertained.
+
+
+### Adding Plugins
+
+Plugins are stored in the `_markbind/plugins` folder which is generated on `init`. To use a plugin, place the `js` source of the plugin in the `_markbind/plugins` folder and add the following options to `site.json`:
+
+- `plugins`: An array of plugin names to use.
+- `pluginsContext`: A mapping of plugin names to parameters passed to each individual plugin. It is recommended to use key-value pairs for consistency.
+
+For example:
+
+```js
+{
+ ...
+ "plugins": [
+ "plugin1",
+ "plugin2",
+ ],
+ "pluginsContext": {
+ "plugin1": {
+ "input": "Input for Plugin 1"
+ },
+ "plugin2": {
+ "data": "Data for Plugin 2"
+ },
+ }
+}
+```
+
+### Writing Plugins
+
+
+
+MarkBind provides two entry points for modifying the page, pre-render and post-render. These are controlled by implementing the `preRender()` and `postRender()` functions in the plugin:
+
+- `preRender(content, pluginContext, frontMatter)`: Called before MarkBind renders the source from Markdown to HTML.
+ - `content`: The raw Markdown of any Markdown file (`.md`, `.mbd`, etc.).
+ - `pluginContext`: User provided parameters for the plugin. This can be specified in the `site.json`.
+ - `frontMatter`: The frontMatter of the page being processed, in case any frontMatter data is required.
+- `postRender(content, pluginContext, frontMatter)`: Called after the HTML is rendered, before writing it to a file.
+ - `content`: The rendered HTML.
+ - `pluginContext`: User provided parameters for the plugin. This can be specified in the `site.json`.
+ - `frontMatter`: The frontMatter of the page being processed, in case any frontMatter data is required.
+
+MarkBind will call these functions with the respective content, and retrieve a string data that is used for the next step of the page generation process.
+
+An example of a plugin is shown below. The plugin shows two ways of appending a paragraph of text to a specific `div` in the Markdown files:
+
+```js
+// myPlugin.js
+
+const cheerio = module.parent.require('cheerio');
+
+module.exports = {
+ preRender: (content, pluginContext, frontMatter) => content.replace('[Pre-render Placeholder]', `${pluginContext.pre}`),
+ postRender: (content, pluginContext, frontMatter) => {
+ const $ = cheerio.load(content, { xmlMode: false });
+ // Modify the page...
+ $('#my-div').append(pluginContext.post);
+ return $.html();
+ },
+};
+```
+
+```js
+// site.json
+
+{
+ ...
+ "plugins": [
+ "myPlugin"
+ ],
+ "pluginsContext": {
+ "myPlugin": {
+ "pre": "
Hello
",
+ "post": "
Goodbye
"
+ }
+ }
+}
+```
+
+```md
+// index.md
+
+...
+
+[Pre-render Placeholder]
+
+```
+
+### Built-in plugins
+
+MarkBind has a set of built-in plugins that can be used immediately without installation.
+
+#### `filterTags`: Toggling alternative contents in a page
+
+You can use tags to selectively filter HTML elements when building a site.
+
+Tags are specified by the `tags` attribute, **and can be attached to any HTML element**. During rendering, only elements that match tags specified in the `site.json` files will be rendered.
+
+
+
+{{ icon_example }} Attaching tags to elements:
+```html
+# Print 'Hello world'
+
+
System.out.println("Hello world");
+
Console.WriteLine("Hello world");
+
print("Hello world")
+```
+
+You need to specify the tags to include in the `pluginsContext`, under `tags`:
+
+```json
+{
+ ...
+ "plugins" : [
+ "filterTags"
+ ],
+ "pluginsContext" : {
+ "filterTags" : {
+ "tags": ["language--java"]
+ }
+ }
+}
+```
+
+All other tagged elements will be filtered out. In this case, only the element with the `language--java` tag will be rendered. This is helpful when creating multiple versions of a page without having to maintain separate copies.
+
+
+
+If the `filterTags` plugin is not enabled in `site.json`, all tagged elements will be rendered.
+
+**You can also use multiple tags in a single HTML element. Specify each tag in the `tags` attribute** separated by a space. An element will be rendered if **any of the tags** matches the one in `site.json`.
+
+
+
+{{ icon_example }} Attaching multiple tags to an element:
+```html
+# For loops
+
+
for (int i = 0; i < 5; i++) { ... }
+```
+
+As long as the `language--java` or `language--C#` tag is specified, the code snippet will be rendered.
+
+
+
+Alternatively, you can specify tags to render for a page in the front matter.
+
+
+
+{{ icon_example }} Specifying tags in front matter:
+```html
+
+ title: "Hello World"
+ tags: ["language--java"]
+
+```
+
+
+Tags in `site.json` will take precedence over the ones in the front matter.
+
+
diff --git a/src/Page.js b/src/Page.js
index aa24ba63dc..0026298b23 100644
--- a/src/Page.js
+++ b/src/Page.js
@@ -62,9 +62,10 @@ function Page(pageConfig) {
this.layoutsAssetPath = pageConfig.layoutsAssetPath;
this.rootPath = pageConfig.rootPath;
this.enableSearch = pageConfig.enableSearch;
+ this.plugins = pageConfig.plugins;
+ this.pluginsContext = pageConfig.pluginsContext;
this.searchable = pageConfig.searchable;
this.src = pageConfig.src;
- this.tags = pageConfig.tags;
this.template = pageConfig.pageTemplate;
this.title = pageConfig.title || '';
this.titlePrefix = pageConfig.titlePrefix;
@@ -447,29 +448,6 @@ Page.prototype.concatenateHeadingsAndKeywords = function () {
});
};
-/**
- * Filters out elements on the page based on config tags
- * @param tags to filter
- * @param content of the page
- */
-Page.prototype.filterTags = function (tags, content) {
- if (tags === undefined) {
- return content;
- }
- const tagsArray = Array.isArray(tags) ? tags : [tags];
- const $ = cheerio.load(content, { xmlMode: false });
- $('[tags]').each((i, element) => {
- $(element).attr('hidden', true);
- });
- tagsArray.forEach((tag) => {
- $(`[tags~="${tag}"]`).each((i, element) => {
- $(element).removeAttr('hidden');
- });
- });
- $('[hidden]').remove();
- return $.html();
-};
-
/**
* Adds anchor links to headings in the page
* @param content of the page
@@ -526,8 +504,6 @@ Page.prototype.collectFrontMatter = function (includedPage) {
this.frontMatter.title = (this.title || this.frontMatter.title || '');
// Layout specified in site.json will override layout specified in the front matter
this.frontMatter.layout = (this.layout || this.frontMatter.layout || LAYOUT_DEFAULT_NAME);
- // Included tags specified in site.json will override included tags specified in front matter
- this.frontMatter.tags = (this.tags || this.frontMatter.tags);
} else {
// Page is addressable but no front matter specified
this.frontMatter = {
@@ -782,6 +758,7 @@ Page.prototype.generate = function (builtFiles) {
return this.removeFrontMatter(result);
})
.then(result => addContentWrapper(result))
+ .then(result => this.preRender(result))
.then(result => this.insertPageNavWrapper(result))
.then(result => this.insertSiteNav((result)))
.then(result => this.insertFooter(result)) // Footer has to be inserted last to ensure proper formatting
@@ -789,8 +766,8 @@ 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.filterTags(this.frontMatter.tags, result))
.then(result => this.addAnchors(result))
+ .then(result => this.postRender(result))
.then((result) => {
this.content = htmlBeautify(result, { indent_size: 2 });
@@ -824,6 +801,34 @@ Page.prototype.generate = function (builtFiles) {
});
};
+/**
+ * Entry point for plugin pre-render
+ */
+Page.prototype.preRender = function (content) {
+ let preRenderedContent = content;
+ Object.entries(this.plugins).forEach(([pluginName, plugin]) => {
+ if (plugin.preRender) {
+ preRenderedContent
+ = plugin.preRender(preRenderedContent, this.pluginsContext[pluginName], this.frontMatter);
+ }
+ });
+ return preRenderedContent;
+};
+
+/**
+ * Entry point for plugin post-render
+ */
+Page.prototype.postRender = function (content) {
+ let postRenderedContent = content;
+ Object.entries(this.plugins).forEach(([pluginName, plugin]) => {
+ if (plugin.postRender) {
+ postRenderedContent
+ = plugin.postRender(postRenderedContent, this.pluginsContext[pluginName], this.frontMatter);
+ }
+ });
+ return postRenderedContent;
+};
+
/**
* Adds linked layout files to page assets
*/
diff --git a/src/Site.js b/src/Site.js
index b2f619935c..f9d8f906b4 100644
--- a/src/Site.js
+++ b/src/Site.js
@@ -34,6 +34,7 @@ const TEMP_FOLDER_NAME = '.temp';
const TEMPLATE_ROOT_FOLDER_NAME = 'template';
const TEMPLATE_SITE_ASSET_FOLDER_NAME = 'markbind';
+const BUILT_IN_PLUGIN_FOLDER_NAME = 'plugins';
const FAVICON_DEFAULT_PATH = 'favicon.ico';
const FONT_AWESOME_PATH = 'asset/font-awesome.csv';
const FOOTER_PATH = '_markbind/footers/footer.md';
@@ -41,6 +42,7 @@ const GLYPHICONS_PATH = 'asset/glyphicons.csv';
const HEAD_FOLDER_PATH = '_markbind/head';
const INDEX_MARKDOWN_FILE = 'index.md';
const PAGE_TEMPLATE_NAME = 'page.ejs';
+const PROJECT_PLUGIN_FOLDER_NAME = '_markbind/plugins';
const SITE_CONFIG_NAME = 'site.json';
const SITE_DATA_NAME = 'siteData.json';
const SITE_NAV_PATH = '_markbind/navigation/site-nav.md';
@@ -135,6 +137,7 @@ function Site(rootPath, outputPath, onePagePath, forceReload = false, siteConfig
this.baseUrlMap = {};
this.forceReload = forceReload;
this.onePagePath = onePagePath;
+ this.plugins = {};
this.siteConfig = {};
this.siteConfigPath = siteConfigPath;
this.userDefinedVariablesMap = {};
@@ -197,6 +200,7 @@ Site.initSite = function (rootPath) {
const siteLayoutPath = path.join(rootPath, LAYOUT_FOLDER_PATH);
const siteLayoutDefaultPath = path.join(siteLayoutPath, LAYOUT_DEFAULT_NAME);
const siteDefaultLayoutScriptsPath = path.join(siteLayoutDefaultPath, LAYOUT_SCRIPTS_PATH);
+ const sitePluginPath = path.join(rootPath, PROJECT_PLUGIN_FOLDER_NAME);
const userDefinedVariablesPath = path.join(rootPath, USER_VARIABLES_PATH);
// TODO: log the generate info
return new Promise((resolve, reject) => {
@@ -281,6 +285,13 @@ Site.initSite = function (rootPath) {
}
return fs.outputFileAsync(siteDefaultLayoutScriptsPath, LAYOUT_SCRIPTS_DEFAULT);
})
+ .then(() => fs.accessAsync(sitePluginPath))
+ .catch(() => {
+ if (fs.existsSync(sitePluginPath)) {
+ return Promise.resolve();
+ }
+ return fs.mkdirp(sitePluginPath);
+ })
.then(resolve)
.catch(reject);
});
@@ -323,9 +334,10 @@ Site.prototype.createPage = function (config) {
baseUrl: this.siteConfig.baseUrl,
baseUrlMap: this.baseUrlMap,
content: '',
+ pluginsContext: this.siteConfig.pluginsContext || {},
faviconUrl: config.faviconUrl,
- tags: this.siteConfig.tags,
pageTemplate: this.pageTemplate,
+ plugins: this.plugins || {},
rootPath: this.rootPath,
enableSearch: this.siteConfig.enableSearch,
searchable: this.siteConfig.enableSearch && config.searchable,
@@ -505,6 +517,7 @@ Site.prototype.generate = function (baseUrl) {
.then(() => this.collectAddressablePages())
.then(() => this.collectBaseUrl())
.then(() => this.collectUserDefinedVariablesMap())
+ .then(() => this.collectPlugins())
.then(() => this.buildAssets())
.then(() => this.buildSourceFiles())
.then(() => this.copyMarkBindAsset())
@@ -642,6 +655,46 @@ Site.prototype.buildAssets = function () {
});
};
+/**
+ * Retrieves the correct plugin path for a plugin name, if not in node_modules
+ * @param pluginName name of the plugin
+ */
+function getPluginPath(rootPath, plugin) {
+ // Check in project folder
+ const pluginPath = path.join(rootPath, PROJECT_PLUGIN_FOLDER_NAME, `${plugin}.js`);
+ if (fs.existsSync(pluginPath)) {
+ return pluginPath;
+ }
+
+ // Check in src folder
+ const defaultPath = path.join(__dirname, BUILT_IN_PLUGIN_FOLDER_NAME, `${plugin}.js`);
+ if (fs.existsSync(defaultPath)) {
+ return defaultPath;
+ }
+
+ return '';
+}
+
+/**
+ * Load all plugins of the site
+ */
+Site.prototype.collectPlugins = function () {
+ if (!this.siteConfig.plugins) {
+ return;
+ }
+ 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`);
+ }
+ });
+};
+
/**
* Renders all pages specified in site configuration file to the output folder
*/
diff --git a/src/plugins/filterTags.js b/src/plugins/filterTags.js
new file mode 100644
index 0000000000..b60c912286
--- /dev/null
+++ b/src/plugins/filterTags.js
@@ -0,0 +1,32 @@
+const cheerio = module.parent.require('cheerio');
+
+/**
+ * Filters out elements on the page based on config tags
+ * @param tags to filter
+ * @param content of the page
+ */
+function filterTags(tags, content) {
+ if (tags === undefined) {
+ return content;
+ }
+ const tagsArray = Array.isArray(tags) ? tags : [tags];
+ const $ = cheerio.load(content, { xmlMode: false });
+ $('[tags]').each((i, element) => {
+ $(element).attr('hidden', true);
+ });
+ tagsArray.forEach((tag) => {
+ $(`[tags~="${tag}"]`).each((i, element) => {
+ $(element).removeAttr('hidden');
+ });
+ });
+ $('[hidden]').remove();
+ return $.html();
+}
+
+module.exports = {
+ postRender: (content, pluginContext, frontMatter) => {
+ // Included tags specified in site.json will override included tags specified in front matter
+ const tags = (pluginContext.tags || frontMatter.tags);
+ return filterTags(tags, content);
+ },
+};
diff --git a/test/functional/test_site/_markbind/plugins/testMarkbindPlugin.js b/test/functional/test_site/_markbind/plugins/testMarkbindPlugin.js
new file mode 100644
index 0000000000..de0d5020b3
--- /dev/null
+++ b/test/functional/test_site/_markbind/plugins/testMarkbindPlugin.js
@@ -0,0 +1,11 @@
+const cheerio = module.parent.require('cheerio');
+
+module.exports = {
+ preRender: (content, pluginContext) =>
+ content.replace('Markbind Plugin Pre-render Placeholder', `${pluginContext.pre}`),
+ postRender: (content, pluginContext) => {
+ const $ = cheerio.load(content, { xmlMode: false });
+ $('#test-markbind-plugin').append(`${pluginContext.post}`);
+ return $.html();
+ },
+};
diff --git a/test/functional/test_site/expected/_markbind/plugins/testMarkbindPlugin.js b/test/functional/test_site/expected/_markbind/plugins/testMarkbindPlugin.js
new file mode 100644
index 0000000000..de0d5020b3
--- /dev/null
+++ b/test/functional/test_site/expected/_markbind/plugins/testMarkbindPlugin.js
@@ -0,0 +1,11 @@
+const cheerio = module.parent.require('cheerio');
+
+module.exports = {
+ preRender: (content, pluginContext) =>
+ content.replace('Markbind Plugin Pre-render Placeholder', `${pluginContext.pre}`),
+ postRender: (content, pluginContext) => {
+ const $ = cheerio.load(content, { xmlMode: false });
+ $('#test-markbind-plugin').append(`${pluginContext.post}`);
+ return $.html();
+ },
+};
diff --git a/test/functional/test_site/expected/index.html b/test/functional/test_site/expected/index.html
index 7f7ea81beb..9cddda4daa 100644
--- a/test/functional/test_site/expected/index.html
+++ b/test/functional/test_site/expected/index.html
@@ -145,6 +145,8 @@
Pane
Panel content inside unexpanded panel should not appear in search data
+ Test plugin in markbind/plugins
+
+
Markbind Plugin Pre-render
+
Node Modules Plugin Post-render
+
diff --git a/test/functional/test_site/expected/siteData.json b/test/functional/test_site/expected/siteData.json
index e125636b7d..bad586e925 100644
--- a/test/functional/test_site/expected/siteData.json
+++ b/test/functional/test_site/expected/siteData.json
@@ -9,11 +9,7 @@
},
"title": "Open Bugs",
"src": "bugs/index.md",
- "layout": "default",
- "tags": [
- "tag--shown",
- "tag--shown-2"
- ]
+ "layout": "default"
},
{
"headings": {
@@ -99,7 +95,9 @@
"nested-panel-without-src": "Nested panel without src",
"panel-with-src-from-another-markbind-site": "Panel with src from another Markbind site",
"modal-with-panel-inside": "Modal with panel inside",
- "unexpanded-panel": "Unexpanded panel"
+ "unexpanded-panel": "Unexpanded panel",
+ "test-plugin-in-markbind-plugins": "Test plugin in markbind/plugins",
+ "markbind-plugin-pre-render": "Markbind Plugin Pre-render"
},
"title": "Hello World",
"footer": "footer.md",
@@ -108,11 +106,7 @@
"pageNavTitle": "Testing Page Navigation",
"head": "myCustomHead.md, myCustomHead2.md",
"src": "index.md",
- "layout": "default",
- "tags": [
- "tag--shown",
- "tag--shown-2"
- ]
+ "layout": "default"
},
{
"headings": {
@@ -134,11 +128,7 @@
"headings": {},
"layout": "testAfterSetup",
"src": "testAfterSetup.md",
- "title": "Hello World",
- "tags": [
- "tag--shown",
- "tag--shown-2"
- ]
+ "title": "Hello World"
},
{
"headings": {},
@@ -159,11 +149,7 @@
"title": "Hello World",
"head": "overwriteLayoutHead.md",
"layout": "testLayout",
- "src": "testLayouts.md",
- "tags": [
- "tag--shown",
- "tag--shown-2"
- ]
+ "src": "testLayouts.md"
},
{
"headings": {
@@ -172,11 +158,7 @@
"title": "Hello World",
"head": "overwriteLayoutHead.md",
"layout": "testLayout",
- "src": "testLayoutsOverride.md",
- "tags": [
- "tag--shown",
- "tag--shown-2"
- ]
+ "src": "testLayoutsOverride.md"
},
{
"headings": {
@@ -189,8 +171,7 @@
},
"title": "Hello World",
"tags": [
- "tag--shown",
- "tag--shown-2"
+ "tag--should-be-overridden"
],
"src": "testTags.md",
"layout": "default"
diff --git a/test/functional/test_site/index.md b/test/functional/test_site/index.md
index 5dd5bc3a84..08bc173b9a 100644
--- a/test/functional/test_site/index.md
+++ b/test/functional/test_site/index.md
@@ -209,3 +209,9 @@ head: myCustomHead.md, myCustomHead2.md
## Panel content inside unexpanded panel should not appear in search data
+
+# Test plugin in markbind/plugins
+
+
+ Markbind Plugin Pre-render Placeholder
+
diff --git a/test/functional/test_site/site.json b/test/functional/test_site/site.json
index 6b2ac10771..65e1584f3f 100644
--- a/test/functional/test_site/site.json
+++ b/test/functional/test_site/site.json
@@ -59,6 +59,18 @@
"deploy": {
"message": "Site Update."
},
- "tags" : ["tag--shown", "tag--shown-2"],
- "headingIndexingLevel": 4
+ "headingIndexingLevel": 4,
+ "plugins" : [
+ "testMarkbindPlugin",
+ "filterTags"
+ ],
+ "pluginsContext" : {
+ "testMarkbindPlugin" : {
+ "pre": "\n\n# Markbind Plugin Pre-render",
+ "post": "