nodebb-plugin-markdown
Version:
A Markdown parser for NodeBB
421 lines (357 loc) • 12.8 kB
JavaScript
;
var MarkdownIt = require('markdown-it');
var fs = require('fs');
var path = require('path');
var url = require('url');
var async = require('async');
var meta = require.main.require('./src/meta');
var translator = require.main.require('./src/translator');
var nconf = require.main.require('nconf');
var winston = require.main.require('winston');
var plugins = module.parent.exports;
var SocketPlugins = require.main.require('./src/socket.io/plugins');
SocketPlugins.markdown = require('./websockets');
var parser;
var Markdown = {
config: {},
onLoad: function (params, callback) {
const controllers = require('./lib/controllers');
const hostMiddleware = require.main.require('./src/middleware');
const middlewares = [hostMiddleware.maintenanceMode, hostMiddleware.registrationComplete, hostMiddleware.pluginHooks];
params.router.get('/admin/plugins/markdown', params.middleware.admin.buildHeader, controllers.renderAdmin);
params.router.get('/api/admin/plugins/markdown', controllers.renderAdmin);
// Return raw markdown via GET
params.router.get('/api/post/:pid/raw', middlewares, controllers.retrieveRaw);
Markdown.init();
Markdown.loadThemes();
callback();
},
getConfig: function (config, callback) {
config.markdown = {
highlight: Markdown.highlight ? 1 : 0,
highlightLinesLanguageList: Markdown.config.highlightLinesLanguageList,
theme: Markdown.config.highlightTheme || 'railscasts.css',
};
callback(null, config);
},
getLinkTags: function (hookData, callback) {
hookData.links.push({
rel: 'prefetch stylesheet',
type: '',
href: nconf.get('relative_path') + '/plugins/nodebb-plugin-markdown/styles/' + (Markdown.config.highlightTheme || 'railscasts.css'),
});
var prefetch = ['/assets/src/modules/highlight.js', '/assets/language/' + (meta.config.defaultLang || 'en-GB') + '/markdown.json'];
hookData.links = hookData.links.concat(prefetch.map(function (path) {
path = {
rel: 'prefetch',
href: nconf.get('relative_path') + path + '?' + meta.config['cache-buster'],
};
return path;
}));
callback(null, hookData);
},
init: function () {
// Load saved config
var _self = this;
var defaults = {
html: false,
xhtmlOut: true,
breaks: true,
langPrefix: 'language-',
linkify: true,
typographer: false,
highlight: true,
highlightLinesLanguageList: [],
highlightTheme: 'railscasts.css',
externalBlank: false,
nofollow: true,
allowRTLO: false,
checkboxes: true,
multimdTables: true,
};
meta.settings.get('markdown', function (err, options) {
if (err) {
winston.warn('[plugin/markdown] Unable to retrieve settings, assuming defaults: ' + err.message);
}
for (var field in defaults) {
// If not set in config (nil)
if (!options.hasOwnProperty(field)) {
_self.config[field] = defaults[field];
} else if (field !== 'langPrefix' && field !== 'highlightTheme' && field !== 'headerPrefix' && field !== 'highlightLinesLanguageList') {
_self.config[field] = options[field] === 'on';
} else {
_self.config[field] = options[field];
}
}
_self.highlight = _self.config.highlight;
delete _self.config.highlight;
if (typeof _self.config.highlightLinesLanguageList === 'string') {
try {
_self.config.highlightLinesLanguageList = JSON.parse(_self.config.highlightLinesLanguageList);
} catch (e) {
winston.warn('[plugins/markdown] Invalid config for highlightLinesLanguageList, blanking.');
_self.config.highlightLinesLanguageList = [];
}
_self.config.highlightLinesLanguageList = _self.config.highlightLinesLanguageList.join(',').split(',');
}
parser = new MarkdownIt(_self.config);
Markdown.updateParserRules(parser);
});
},
loadThemes: function () {
fs.readdir(path.join(require.resolve('highlight.js'), '../../styles'), function (err, files) {
if (err) {
winston.error('[plugin/markdown] Could not load Markdown themes: ' + err.message);
Markdown.themes = [];
return;
}
var isStylesheet = /\.css$/;
Markdown.themes = files.filter(function (file) {
return isStylesheet.test(file);
}).map(function (file) {
return {
name: file,
};
});
});
},
parsePost: function (data, callback) {
async.waterfall([
function (next) {
if (data && data.postData && data.postData.content && parser) {
data.postData.content = parser.render(data.postData.content);
}
next(null, data);
},
async.apply(Markdown.postParse),
], callback);
},
parseSignature: function (data, callback) {
async.waterfall([
function (next) {
if (data && data.userData && data.userData.signature && parser) {
data.userData.signature = parser.render(data.userData.signature);
}
next(null, data);
},
async.apply(Markdown.postParse),
], callback);
},
parseAboutMe: function (aboutme, callback) {
async.waterfall([
function (next) {
aboutme = (aboutme && parser) ? parser.render(aboutme) : aboutme;
process.nextTick(next, null, aboutme);
},
async.apply(Markdown.postParse),
], callback);
},
parseRaw: function (raw, callback) {
async.waterfall([
function (next) {
raw = (raw && parser) ? parser.render(raw) : raw;
process.nextTick(next, null, raw);
},
async.apply(Markdown.postParse),
], callback);
},
postParse: function (payload, next) {
var italicMention = /@<em>([^<]+)<\/em>/g;
var boldMention = /@<strong>([^<]+)<\/strong>/g;
var execute = function (html) {
// Replace all italicised mentions back to regular mentions
if (italicMention.test(html)) {
html = html.replace(italicMention, function (match, slug) {
return '@_' + slug + '_';
});
} else if (boldMention.test(html)) {
html = html.replace(boldMention, function (match, slug) {
return '@__' + slug + '__';
});
}
return html;
};
if (payload.hasOwnProperty('postData')) {
payload.postData.content = execute(payload.postData.content);
} else if (payload.hasOwnProperty('userData')) {
payload.userData.signature = execute(payload.userData.signature);
} else {
payload = execute(payload);
}
next(null, payload);
},
renderHelp: function (helpContent, callback) {
translator.translate('[[markdown:help_text]]', function (translated) {
plugins.fireHook('filter:parse.raw', '## Markdown\n' + translated, function (err, parsed) {
if (err) {
return callback(err);
}
helpContent += parsed;
callback(null, helpContent);
});
});
},
registerFormatting: function (payload, callback) {
var formatting = [
{ name: 'bold', className: 'fa fa-bold', title: '[[modules:composer.formatting.bold]]' },
{ name: 'italic', className: 'fa fa-italic', title: '[[modules:composer.formatting.italic]]' },
{ name: 'list', className: 'fa fa-list-ul', title: '[[modules:composer.formatting.list]]' },
{ name: 'strikethrough', className: 'fa fa-strikethrough', title: '[[modules:composer.formatting.strikethrough]]' },
{ name: 'code', className: 'fa fa-code', title: '[[modules:composer.formatting.code]]' },
{ name: 'link', className: 'fa fa-link', title: '[[modules:composer.formatting.link]]' },
{ name: 'picture-o', className: 'fa fa-picture-o', title: '[[modules:composer.formatting.picture]]' },
];
payload.options = formatting.concat(payload.options);
callback(null, payload);
},
updateSanitizeConfig: async (config) => {
config.allowedTags.push('input');
config.allowedAttributes.input = ['type', 'checked'];
config.allowedAttributes.ol.push('start');
config.allowedAttributes.th.push('colspan', 'rowspan');
config.allowedAttributes.td.push('colspan', 'rowspan');
return config;
},
updateParserRules: function (parser) {
if (Markdown.config.checkboxes) {
// Add support for checkboxes
parser.use(require('markdown-it-checkbox'), {
divWrap: true,
divClass: 'plugin-markdown',
});
}
if (Markdown.config.multimdTables) {
parser.use(require('markdown-it-multimd-table'), {
multiline: true,
rowspan: true,
headerless: true,
})
}
parser.use((md) => {
md.core.ruler.before('linkify', 'autodir', (state) => {
state.tokens.forEach((token) => {
if (token.type === 'paragraph_open') {
token.attrJoin('dir', 'auto');
}
});
});
});
// Update renderer to add some classes to all images
var renderImage = parser.renderer.rules.image || function (tokens, idx, options, env, self) {
return self.renderToken.apply(self, arguments);
};
var renderLink = parser.renderer.rules.link_open || function (tokens, idx, options, env, self) {
return self.renderToken.apply(self, arguments);
};
var renderTable = parser.renderer.rules.table_open || function (tokens, idx, options, env, self) {
return self.renderToken.apply(self, arguments);
};
parser.renderer.rules.image = function (tokens, idx, options, env, self) {
var classIdx = tokens[idx].attrIndex('class');
var srcIdx = tokens[idx].attrIndex('src');
// Validate the url
if (!Markdown.isUrlValid(tokens[idx].attrs[srcIdx][1])) { return ''; }
if (classIdx < 0) {
tokens[idx].attrPush(['class', 'img-responsive img-markdown']);
} else {
tokens[idx].attrs[classIdx][1] += ' img-responsive img-markdown';
}
return renderImage(tokens, idx, options, env, self);
};
parser.renderer.rules.link_open = function (tokens, idx, options, env, self) {
// Add target="_blank" to all links
var targetIdx = tokens[idx].attrIndex('target');
var relIdx = tokens[idx].attrIndex('rel');
var hrefIdx = tokens[idx].attrIndex('href');
if (Markdown.isExternalLink(tokens[idx].attrs[hrefIdx][1])) {
if (Markdown.config.externalBlank) {
if (targetIdx < 0) {
tokens[idx].attrPush(['target', '_blank']);
} else {
tokens[idx].attrs[targetIdx][1] = '_blank';
}
if (relIdx < 0) {
tokens[idx].attrPush(['rel', 'noopener noreferrer']);
relIdx = tokens[idx].attrIndex('rel');
} else {
tokens[idx].attrs[relIdx][1] = 'noopener noreferrer';
}
}
if (Markdown.config.nofollow) {
if (relIdx < 0) {
tokens[idx].attrPush(['rel', 'nofollow']);
} else {
tokens[idx].attrs[relIdx][1] += ' nofollow';
}
}
}
if (!Markdown.config.allowRTLO) {
if (tokens[idx + 1] && tokens[idx + 1].type === 'text') {
if (tokens[idx + 1].content.match(Markdown.regexes.rtl_override)) {
tokens[idx + 1].content = tokens[idx + 1].content.replace(Markdown.regexes.rtl_override, '');
}
}
}
return renderLink(tokens, idx, options, env, self);
};
parser.renderer.rules.table_open = function (tokens, idx, options, env, self) {
var classIdx = tokens[idx].attrIndex('class');
if (classIdx < 0) {
tokens[idx].attrPush(['class', 'table table-bordered table-striped']);
} else {
tokens[idx].attrs[classIdx][1] += ' table table-bordered table-striped';
}
return renderTable(tokens, idx, options, env, self);
};
plugins.fireHook('action:markdown.updateParserRules', parser);
},
isUrlValid: function (src) {
/**
* Images linking to a relative path are only allowed from the root prefixes
* defined in allowedRoots. We allow both with and without relative_path
* even though upload_url should handle it, because sometimes installs
* migrate to (non-)subfolder and switch mid-way, but the uploads urls don't
* get updated.
*/
const allowedRoots = [nconf.get('upload_url'), '/uploads'];
const allowed = pathname => allowedRoots.some(root => pathname.toString().startsWith(root) || pathname.toString().startsWith(nconf.get('relative_path') + root));
try {
var urlObj = url.parse(src, false, true);
return !(urlObj.host === null && !allowed(urlObj.pathname));
} catch (e) {
return false;
}
},
isExternalLink: function (urlString) {
var urlObj;
var baseUrlObj;
try {
urlObj = url.parse(urlString);
baseUrlObj = url.parse(nconf.get('url'));
} catch (err) {
return false;
}
if (
urlObj.host === null // Relative paths are always internal links...
|| (urlObj.host === baseUrlObj.host && urlObj.protocol === baseUrlObj.protocol // Otherwise need to check that protocol and host match
&& (nconf.get('relative_path').length > 0 ? urlObj.pathname.indexOf(nconf.get('relative_path')) === 0 : true)) // Subfolder installs need this additional check
) {
return false;
}
return true;
},
admin: {
menu: function (custom_header, callback) {
custom_header.plugins.push({
route: '/plugins/markdown',
icon: 'fa-edit',
name: 'Markdown',
});
callback(null, custom_header);
},
},
regexes: {
rtl_override: /\u202E/gi,
},
};
module.exports = Markdown;