Skip to content

Commit

Permalink
Add plugin local asset collection (#1129)
Browse files Browse the repository at this point in the history
Plugins might want to package their own assets for use instead of
relying on external sources only.
The main markbind code also has some amount of logic and styles
relating to the anchor plugin.

Let's add this, allowing the getLinks and getScripts methods to return
link and script elements that have a relative or absolute file path as
its src or href attributes.
Let's encapsulate the anchor plugin's logic and styles within the
plugin itself, which should lead to better code readability.
This also allows the user to turn off the plugin without risk of side
effects.
  • Loading branch information
ang-zeyu authored Apr 11, 2020
2 parents 70b2af8 + f672dcf commit 248898d
Show file tree
Hide file tree
Showing 91 changed files with 696 additions and 633 deletions.
14 changes: 0 additions & 14 deletions asset/css/markbind.css
Original file line number Diff line number Diff line change
Expand Up @@ -73,20 +73,6 @@ kbd {
outline: none !important;
}

.fa.fa-anchor {
color: #ccc;
display: inline;
font-size: 14px;
margin-left: 10px;
padding: 3px;
text-decoration: none;
visibility: hidden;
}

.fa.fa-anchor:hover {
color: #555;
}

code.hljs.inline {
background: #f8f8f8;
color: #333;
Expand Down
63 changes: 23 additions & 40 deletions asset/js/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,63 +13,47 @@ function scrollToUrlAnchorHeading() {
}
}

function flattenModals() {
jQuery('.modal').each((index, modal) => {
jQuery(modal).detach().appendTo(jQuery('#app'));
});
}

function insertCss(cssCode) {
const newNode = document.createElement('style');
newNode.innerHTML = cssCode;
document.getElementsByTagName('head')[0].appendChild(newNode);
}

function setupAnchors() {
function setupAnchorsForFixedNavbar() {
const headerSelector = jQuery('header');
const isFixed = headerSelector.filter('.header-fixed').length !== 0;
if (!isFixed) {
return;
}

const headerHeight = headerSelector.height();
const bufferHeight = 1;
if (isFixed) {
jQuery('.nav-inner').css('padding-top', `calc(${headerHeight}px)`);
jQuery('#content-wrapper').css('padding-top', `calc(${headerHeight}px)`);
insertCss(
`span.anchor {
display: block;
position: relative;
top: calc(-${headerHeight}px - ${bufferHeight}rem)
}`,
);
}
jQuery('.nav-inner').css('padding-top', `calc(${headerHeight}px)`);
jQuery('#content-wrapper').css('padding-top', `calc(${headerHeight}px)`);
insertCss(
`span.anchor {
display: block;
position: relative;
top: calc(-${headerHeight}px - ${bufferHeight}rem)
}`,
);
jQuery('h1, h2, h3, h4, h5, h6, .header-wrapper').each((index, heading) => {
if (heading.id) {
jQuery(heading).on('mouseenter',
() => jQuery(heading).find('.fa.fa-anchor').css('visibility', 'visible'));
jQuery(heading).on('mouseleave',
() => jQuery(heading).find('.fa.fa-anchor').css('visibility', 'hidden'));
if (isFixed) {
/**
* Fixing the top navbar would break anchor navigation,
* by creating empty spans above the <h> tag we can prevent
* the headings from being covered by the navbar.
*/
const spanId = heading.id;
heading.insertAdjacentHTML('beforebegin', `<span id="${spanId}" class="anchor"></span>`);
jQuery(heading).removeAttr('id'); // to avoid duplicated id problem
}
/**
* Fixing the top navbar would break anchor navigation,
* by creating empty spans above the <h> tag we can prevent
* the headings from being covered by the navbar.
*/
const spanId = heading.id;
heading.insertAdjacentHTML('beforebegin', `<span id="${spanId}" class="anchor"></span>`);
jQuery(heading).removeAttr('id'); // to avoid duplicated id problem
}
});
jQuery('.fa-anchor').each((index, anchor) => {
jQuery(anchor).on('click', function () {
window.location.href = jQuery(this).attr('href');
});
});
}

function updateSearchData(vm) {
jQuery.getJSON(`${baseUrl}/siteData.json`)
.then((siteData) => {
// eslint-disable-next-line no-param-reassign
vm.searchData = siteData.pages;
});
}
Expand Down Expand Up @@ -97,9 +81,8 @@ function executeAfterCreatedRoutines() {
}

function executeAfterMountedRoutines() {
flattenModals();
scrollToUrlAnchorHeading();
setupAnchors();
setupAnchorsForFixedNavbar();
MarkBind.executeAfterSetupScripts.resolve();
}

Expand Down
11 changes: 9 additions & 2 deletions docs/userGuide/usingPlugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,21 @@ Plugins can implement the methods `getLinks` and `getScripts` to add additional
- `frontMatter`: The frontMatter of the page being processed, in case any frontMatter data is required.
- `utils`: Object containing the following utility functions
- `buildStylesheet(href)`: Builds a stylesheet link element with the specified `href`.
- Should return an array of string data containing link elements to be added.
- Should return an array of strings containing link elements to be added.
- `getScripts(content, pluginContext, frontMatter, utils)`: Called to get script elements to be added after the body of the page.
- `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.
- `utils`: Object containing the following utility functions
- `buildScript(src)`: Builds a script element with the specified `src`.
- Should return an array of string data containing script elements to be added.
- Should return an array of strings containing script elements to be added.

<box type="success" header="Local assets">
<md>
You can set an absolute or relative file path as the `src` or `href` attribute in your `<script>` or `<link>` tags.
MarkBind will copy these assets into the output directory and change the `src` or `href` attributes automatically!
</md>
</box>

An example of a plugin which adds links and scripts to the page:

Expand Down
49 changes: 47 additions & 2 deletions src/Page.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const {
FRONT_MATTER_NONE_ATTR,
PAGE_NAV_ID,
PAGE_NAV_TITLE_CLASS,
PLUGIN_SITE_ASSET_FOLDER_NAME,
SITE_NAV_ID,
SITE_NAV_LIST_CLASS,
TITLE_PREFIX_SEPARATOR,
Expand Down Expand Up @@ -1118,6 +1119,45 @@ class Page {
return postRenderedContent;
}

/**
* Resolves a resource specified as an attribute in a html asset tag
* (eg. '<script>' or '<link>') provided by a plugin, and copies said asset
* into the plugin's asset output folder.
* Does nothing if the resource is a url.
* @param assetElementHtml The asset element html, as a string, such as '<script src="...">'
* @param tagName The name of the resource tag
* @param attrName The attribute name where the resource is specified in the tag
* @param plugin The plugin object from which to retrieve its asset src and output paths
* @param pluginName The name of the plugin, used to determine a unique output path for the plugin
* @return String html of the element, with the attribute's asset resolved
*/
getResolvedAssetElement(assetElementHtml, tagName, attrName, plugin, pluginName) {
const $ = cheerio.load(assetElementHtml, { xmlMode: false });
const el = $(`${tagName}[${attrName}]`);

el.attr(attrName, (i, assetPath) => {
if (!assetPath || utils.isUrl(assetPath)) {
return assetPath;
}

const srcPath = path.resolve(plugin._pluginAbsolutePath, assetPath);
const srcBaseName = path.basename(srcPath);

fs.existsAsync(plugin._pluginAssetOutputPath)
.then(exists => exists || fs.mkdirp(plugin._pluginAssetOutputPath))
.then(() => {
const outputPath = path.join(plugin._pluginAssetOutputPath, srcBaseName);
fs.copyAsync(srcPath, outputPath, { overwrite: false });
})
.catch(err => logger.error(`Failed to copy asset ${assetPath} for plugin ${pluginName}\n${err}`));

return path.posix.join(this.baseUrl || '/', PLUGIN_SITE_ASSET_FOLDER_NAME, pluginName, srcBaseName);
});

return $.html();
}


/**
* Collect page content inserted by plugins
*/
Expand All @@ -1134,12 +1174,17 @@ class Page {
if (plugin.getLinks) {
const pluginLinks = plugin.getLinks(content, this.pluginsContext[pluginName],
this.frontMatter, linkUtils);
links = links.concat(pluginLinks);
const resolvedPluginLinks = pluginLinks.map(linkHtml =>
this.getResolvedAssetElement(linkHtml, 'link', 'href', plugin, pluginName));
links = links.concat(resolvedPluginLinks);
}

if (plugin.getScripts) {
const pluginScripts = plugin.getScripts(content, this.pluginsContext[pluginName],
this.frontMatter, scriptUtils);
scripts = scripts.concat(pluginScripts);
const resolvedPluginScripts = pluginScripts.map(scriptHtml =>
this.getResolvedAssetElement(scriptHtml, 'script', 'src', plugin, pluginName));
scripts = scripts.concat(resolvedPluginScripts);
}
});
this.asset.pluginLinks = links;
Expand Down
10 changes: 10 additions & 0 deletions src/Site.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const {
MARKBIND_WEBSITE_URL,
PAGE_TEMPLATE_NAME,
PROJECT_PLUGIN_FOLDER_NAME,
PLUGIN_SITE_ASSET_FOLDER_NAME,
SITE_ASSET_FOLDER_NAME,
SITE_CONFIG_NAME,
SITE_DATA_NAME,
Expand Down Expand Up @@ -892,6 +893,15 @@ class Site {

// eslint-disable-next-line global-require, import/no-dynamic-require
this.plugins[plugin] = require(pluginPath || plugin);

if (!this.plugins[plugin].getLinks && !this.plugins[plugin].getScripts) {
return;
}

// For resolving plugin asset source paths later
this.plugins[plugin]._pluginAbsolutePath = path.dirname(require.resolve(pluginPath || plugin));
this.plugins[plugin]._pluginAssetOutputPath = path.resolve(this.outputPath,
PLUGIN_SITE_ASSET_FOLDER_NAME, plugin);
} catch (e) {
logger.warn(`Unable to load plugin ${plugin}, skipping`);
}
Expand Down
5 changes: 1 addition & 4 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ module.exports = {
SITE_FOLDER_NAME: '_site',
TEMP_FOLDER_NAME: '.temp',
TEMPLATE_SITE_ASSET_FOLDER_NAME: 'markbind',
PLUGIN_SITE_ASSET_FOLDER_NAME: 'plugins',

ABOUT_MARKDOWN_FILE: 'about.md',
BUILT_IN_PLUGIN_FOLDER_NAME: 'plugins',
Expand All @@ -69,10 +70,6 @@ module.exports = {
ALGOLIA_JS_URL: 'https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.js',
ALGOLIA_INPUT_SELECTOR: '#algolia-search-input',

// src/plugins/default/markbind-plugin-anchors.js
ANCHOR_HTML: '<a class="fa fa-anchor" href="#"></a>',
HEADER_TAGS: 'h1, h2, h3, h4, h5, h6',

// src/plugins/default/markbind-plugin-plantuml.js
ERR_PROCESSING: 'Error processing',
ERR_READING: 'Error reading',
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/codeBlockCopyButtons.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const copyCodeBlockScript = `<script>


module.exports = {
getScripts: () => copyCodeBlockScript,
getScripts: () => [copyCodeBlockScript],
postRender: (content) => {
const $ = cheerio.load(content, { xmlMode: false });
const codeBlockSelector = 'pre';
Expand Down
23 changes: 23 additions & 0 deletions src/plugins/default/markbind-plugin-anchors.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.fa.fa-anchor {
color: #ccc;
display: inline;
font-size: 14px;
margin-left: 10px;
padding: 3px;
text-decoration: none;
visibility: hidden;
}

.fa.fa-anchor:hover {
color: #555;
}

h1:hover > .fa.fa-anchor,
h2:hover > .fa.fa-anchor,
h3:hover > .fa.fa-anchor,
h4:hover > .fa.fa-anchor,
h5:hover > .fa.fa-anchor,
h6:hover > .fa.fa-anchor,
.header-wrapper:hover > .fa.fa-anchor {
visibility: visible;
}
11 changes: 5 additions & 6 deletions src/plugins/default/markbind-plugin-anchors.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
const cheerio = module.parent.require('cheerio');

const {
ANCHOR_HTML,
HEADER_TAGS,
} = require('../../constants');
const CSS_FILE_NAME = 'markbind-plugin-anchors.css';

/**
* Adds anchor links to headers
*/
module.exports = {
getLinks: (content, pluginContext, frontMatter, utils) => [utils.buildStylesheet(CSS_FILE_NAME)],
postRender: (content) => {
const $ = cheerio.load(content, { xmlMode: false });
$(HEADER_TAGS).each((i, heading) => {
$(':header').each((i, heading) => {
if ($(heading).attr('id')) {
$(heading).append(ANCHOR_HTML.replace('#', `#${$(heading).attr('id')}`));
$(heading).append(
`<a class="fa fa-anchor" href="#${$(heading).attr('id')}" onclick="event.stopPropagation()"></a>`);
}
});
return $.html();
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/googleAnalytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ function getGoogleAnalyticsTrackingCode(pluginContext) {

module.exports = {
// eslint-disable-next-line no-unused-vars
getScripts: (content, pluginContext, frontMatter, utils) => getGoogleAnalyticsTrackingCode(pluginContext),
getScripts: (content, pluginContext, frontMatter, utils) => [getGoogleAnalyticsTrackingCode(pluginContext)],
};
6 changes: 2 additions & 4 deletions src/util/pluginUtil.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const cryptoJS = require('crypto-js');
const fs = require('fs');
const fs = require('fs-extra-promise');
const path = require('path');
const fsUtil = require('./fsUtil');
const logger = require('./logger');
Expand All @@ -8,7 +8,7 @@ const {
ERR_READING,
} = require('../constants');

const pluginUtil = {
module.exports = {
/**
* Returns the file path for the plugin tag.
* Return based on given name if provided, or it will be based on src.
Expand Down Expand Up @@ -49,5 +49,3 @@ const pluginUtil = {
return $(element).text();
},
};

module.exports = pluginUtil;
13 changes: 10 additions & 3 deletions test/functional/test_site/_markbind/plugins/testMarkbindPlugin.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
const cheerio = module.parent.require('cheerio');
const path = require('path');

const TEST_STYLESHEET_FILE = 'testMarkbindPluginStylesheet.css';
const TEST_SCRIPT_FILE = 'testMarkbindPluginScript.js';

module.exports = {
preRender: (content, pluginContext) =>
Expand All @@ -8,7 +12,10 @@ module.exports = {
$('#test-markbind-plugin').append(`${pluginContext.post}`);
return $.html();
},
getLinks: (content, pluginContext, frontMatter, utils) => [utils.buildStylesheet('STYLESHEET_LINK')],
getScripts: (content, pluginContext, frontMatter, utils) =>
[utils.buildScript('SCRIPT_LINK'), '<script>alert("hello")</script>'],
getLinks: (content, pluginContext, frontMatter, utils) => [utils.buildStylesheet(TEST_STYLESHEET_FILE)],
getScripts: (content, pluginContext, frontMatter, utils) => [
// Explicitly resolve to test absolute file paths
utils.buildScript(path.resolve(__dirname, TEST_SCRIPT_FILE)),
'<script>alert("Inline plugin script loaded!")</script>',
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line no-alert
alert('External plugin script file loaded!');
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
strong {
color: #138bf0;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
const cheerio = module.parent.require('cheerio');
const path = require('path');

const TEST_STYLESHEET_FILE = 'testMarkbindPluginStylesheet.css';
const TEST_SCRIPT_FILE = 'testMarkbindPluginScript.js';

module.exports = {
preRender: (content, pluginContext) =>
Expand All @@ -8,7 +12,10 @@ module.exports = {
$('#test-markbind-plugin').append(`${pluginContext.post}`);
return $.html();
},
getLinks: (content, pluginContext, frontMatter, utils) => [utils.buildStylesheet('STYLESHEET_LINK')],
getScripts: (content, pluginContext, frontMatter, utils) =>
[utils.buildScript('SCRIPT_LINK'), '<script>alert("hello")</script>'],
getLinks: (content, pluginContext, frontMatter, utils) => [utils.buildStylesheet(TEST_STYLESHEET_FILE)],
getScripts: (content, pluginContext, frontMatter, utils) => [
// Explicitly resolve to test absolute file paths
utils.buildScript(path.resolve(__dirname, TEST_SCRIPT_FILE)),
'<script>alert("Inline plugin script loaded!")</script>',
],
};
Loading

0 comments on commit 248898d

Please sign in to comment.