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 Rendering]({{baseUrl}}/images/rendering.png) + +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 @@ + Test plugin in markbind/plugins‎ + Markbind Plugin Pre-render‎ @@ -505,6 +507,11 @@

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": "

Node Modules Plugin Post-render

" + }, + "filterTags" : { + "tags" : ["tag--shown", "tag--shown-2"] + } + } } diff --git a/test/unit/Site.test.js b/test/unit/Site.test.js index cfc6308f40..e97cee8c2a 100644 --- a/test/unit/Site.test.js +++ b/test/unit/Site.test.js @@ -94,7 +94,7 @@ test('Site Init in existing directory generates correct assets', async () => { await Site.initSite(''); const paths = Object.keys(fs.vol.toJSON()); const originalNumFiles = Object.keys(json).length; - const expectedNumBuilt = 12; + const expectedNumBuilt = 13; expect(paths.length).toEqual(originalNumFiles + expectedNumBuilt); // _boilerplates @@ -124,6 +124,9 @@ test('Site Init in existing directory generates correct assets', async () => { expect(fs.readFileSync(path.resolve(`_markbind/layouts/default/${layoutFile}`), 'utf8')).toEqual('')); expect(fs.readFileSync(path.resolve('_markbind/layouts/default/scripts.js'), 'utf8')) .toEqual(LAYOUT_SCRIPTS_DEFAULT); + + // plugins folder + expect(fs.existsSync(path.resolve('_markbind/plugins'), 'utf8')).toEqual(true); }); test('Site Init in directory which does not exist generates correct assets', async () => { @@ -135,7 +138,7 @@ test('Site Init in directory which does not exist generates correct assets', asy await Site.initSite('newDir'); const paths = Object.keys(fs.vol.toJSON()); const originalNumFiles = Object.keys(json).length; - const expectedNumBuilt = 12; + const expectedNumBuilt = 13; expect(paths.length).toEqual(originalNumFiles + expectedNumBuilt); @@ -168,6 +171,9 @@ test('Site Init in directory which does not exist generates correct assets', asy .toEqual('')); expect(fs.readFileSync(path.resolve('newDir/_markbind/layouts/default/scripts.js'), 'utf8')) .toEqual(LAYOUT_SCRIPTS_DEFAULT); + + // plugins folder + expect(fs.existsSync(path.resolve('newDir/_markbind/plugins'), 'utf8')).toEqual(true); }); test('Site baseurls are correct for sub nested subsites', async () => {