@felixrydberg/discord-markdown
Version:
A markdown parser that matches Discords markdown spec.
443 lines (415 loc) • 15.3 kB
JavaScript
import markdown from '@khanacademy/simple-markdown';
import highlightjs from 'highlight.js';
import * as index from './index.css';
// import './index.d.ts';
const patterns = {
br: /^\n/,
text: /^[\s\S]+?(?=[^0-9A-Za-z\s\u00c0-\uffff-]|\n\n|\n|\w+:\S|$)/,
user: /^<@!?([0-9]*)>/,
channel: /^<#?([0-9]*)>/,
role: /^<@&([0-9]*)>/,
emoji: /^<(a?):(\w+):(\d+)>/,
everyone: /^@everyone/,
here: /^@here/,
italics: /^\b_((?:__|\\[\s\S]|[^\\_])+?)_\b|^\*(?=\S)((?:\*\*|\\[\s\S]|\s+(?:\\[\s\S]|[^\s\*\\]|\*\*)|[^\s\*\\])+?)\*(?!\*)/,
bold: /^\*\*((?:\\[\s\S]|[^\\])+?)\*\*(?!\*)/,
underline: /^__((?:\\[\s\S]|[^\\])+?)__(?!_)/,
strikethrough: /^~~(?=\S)((?:\\[\s\S]|~(?!~)|[^\s~\\]|\s(?!~~))+?)~~/,
heading: /^ *(#{1,3})( *.*)[\n]?/,
autolink: /^<?(https?:\/\/[a-z0-9.]+)[>]?/,
link: /^\[([^\]]+)\]\(([(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&=]*))\)/,
unordered_lists: /^( *)((?:[*+-]|\d+\.)) [\s\S]+?(?:\n(?!\1(?:[*+-]|\d+\.|\s*) )|$)/,
ordered_lists: /^( *)((?:\d.|\d+\.)) [\s\S]+?(?:\n(?!\1(?:\d.|\d+\.|\s*) )|$)/,
blockQuote: {
match: {
main: /^$|\n *$/,
ternary: /^( *>>> ([\s\S]*))|^( *> [^\n]*(\n *> [^\n]*)*\n?)/,
},
isBlock: /^ *>>> ?/,
removeSyntaxRegex: {
true: /^ *>>> ?/,
false: /^ *> ?/gm,
},
},
codeBlock: /^```(([a-z0-9+#]+?)\n+)?\n*([^]+?)\n*```/i,
};
const inlineRegex = (pattern) => {
return (source, state, prevCapture) => {
const initial = state.inline;
state.inline = true;
const result = markdown.inlineRegex(pattern)(source, state, prevCapture);
state.inline = initial;
return result;
};
};
const rules = {
// General Rules
text: Object.assign({ }, markdown.defaultRules.text, {
match: inlineRegex(patterns.text),
html: function (node, output, state) {
if (state.escapeHTML){
return markdown.sanitizeText(node.content);
}
return node.content;
}
}),
br: Object.assign({ }, markdown.defaultRules.br, {
match: (source, state, prevCapture) => {
return markdown.anyScopeRegex(patterns.br)(source, state, prevCapture);
},
html: (node) => {
return '<br>';
},
}),
// Discord Mentions
'@user': {
order: 22,
match: source => patterns.user.exec(source),
parse: (capture) => {
return {
id: capture[1]
};
},
html: (node, output, state) => { return getHTML('span', state.mentions.user(node), { class: 'd-mention d-user' }); }
},
'#channel': {
order: 22,
match: source => patterns.channel.exec(source),
parse: (capture) => {
return {
id: capture[1]
};
},
html: (node, output, state) => { return getHTML('span', state.mentions.channel(node), { class: 'd-mention d-channel' }); }
},
'@role': {
order: 22,
match: source => patterns.role.exec(source),
parse: (capture) => {
return {
id: capture[1]
};
},
html: (node, output, state) => { return getHTML('span', state.mentions.role(node), { class: 'd-mention d-role' }); }
},
'@everyone': {
order: 22,
match: source => patterns.everyone.exec(source),
parse: () => {
return {};
},
html: (node, output, state) => { return getHTML('span', state.mentions.everyone(node), { class: 'd-mention d-everyone' }); }
},
'@here': {
order: 22,
match: source => patterns.here.exec(source),
parse: () => {
return {};
},
html: (node, output, state) => { return getHTML('span', state.mentions.here(node), { class: 'd-mention d-here' }); }
},
// Text Formatting
italics: Object.assign({}, markdown.defaultRules.em, {
match: inlineRegex(patterns.italics),
html: (node, output, state) => { return getHTML('em', output(node.content, state), { class: 'd-text d-italics' }, state); }
}),
bold: Object.assign({}, markdown.defaultRules.strong, {
match: inlineRegex(patterns.bold),
html: (node, output, state) => { return getHTML('strong', output(node.content, state), { class: 'd-text d-bold' }, state); }
}),
underline: Object.assign({}, markdown.defaultRules.u, {
match: inlineRegex(patterns.underline),
html: (node, output, state) => { return getHTML('u', output(node.content, state), { class: 'd-text d-underline' }, state); }
}),
strikethrough: Object.assign({}, markdown.defaultRules.del, {
match: inlineRegex(patterns.strikethrough),
html: (node, output, state) => { return getHTML('del', output(node.content, state), { class: 'd-text d-strikethrough' }, state); }
}),
// Organizational Text Formatting
heading: Object.assign({}, markdown.defaultRules.heading, {
match: markdown.blockRegex(patterns.heading),
html: (node, output, state) => { return getHTML(`h${node.level}`, output(node.content, state), { class: 'd-header' }, state); }
}),
unordered_lists : Object.assign({}, markdown.defaultRules.list, {
match: source => patterns.unordered_lists.exec(source),
parse: (capture, parse, state) => {
const arr = capture[0].split(/\n/);
const result = [];
const indent = /^\s*/.exec(arr[0])[0].length;
let latest_parent = {};
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (/^\s*$/.exec(item) !== null) {
continue;
}
const item_indent = /^\s*/.exec(item)[0].length;
const obj = {
text: item.replace(/^\s*[*+-]\s/, ''),
items: [],
};
if (indent === item_indent) {
latest_parent = obj;
result.push(obj);
} else {
latest_parent.items.push(obj);
}
}
return {
items: result
};
},
html: (node, output, state) => {
const listItems = getNestedHTML(node.items, {type: 'ul', classes: {list: 'd-list d-unordered-lists', item: 'd-list-item'}});
return getHTML('ul', listItems, {class: 'd-list d-unordered-lists d-list-parent'});
},
}),
ordered_lists : Object.assign({}, markdown.defaultRules.list, {
match: source => patterns.ordered_lists.exec(source),
parse: (capture, parse, state) => {
const arr = capture[0].split(/\n/);
const result = [];
const indent = /^\s*/.exec(arr[0])[0].length;
let latest_parent = {};
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (/^\s*$/.exec(item) !== null) {
continue;
}
const item_indent = /^\s*/.exec(item)[0].length;
const obj = {
text: item.replace(/^\s*[\d]\.\s/, ''),
items: [],
};
if (indent === item_indent) {
latest_parent = obj;
result.push(obj);
} else {
latest_parent.items.push(obj);
}
}
return {
items: result
};
},
html: (node, output, state) => {
const listItems = getNestedHTML(node.items, {type: 'ol', classes: {list: 'd-list d-ordered-lists', item: 'd-list-item'}});
return getHTML('ol', listItems, {class: 'd-list d-ordered-lists d-list-parent'});
},
}),
// Block quotes
blockQuote: Object.assign({}, markdown.defaultRules.blockQuote, {
match: (source, state, prevSource) => {
return !/^$|\n *$/.test(prevSource) || state.inQuote ? null : /^( *>>> ([\s\S]*))|^( *> [^\n]*(\n *> [^\n]*)*\n?)/.exec(source);
},
parse: (capture, parse, state) => {
const all = capture[0];
const isBlock = Boolean(/^ *>>> ?/.exec(all));
const removeSyntaxRegex = isBlock ? /^ *>>> ?/ : /^ *> ?/gm;
const content = all.replace(removeSyntaxRegex, '');
return {
content: parse(content, Object.assign({ }, state, { inQuote: true })),
type: 'blockQuote'
};
}
}),
// Code block
codeBlock: Object.assign({}, markdown.defaultRules.codeBlock, {
match: inlineRegex(patterns.codeBlock),
parse: (capture, parse, state) => {
return {
lang: (capture[2] || '').trim(),
content: capture[3] || '',
inQuote: state.inQuote || false
};
},
html: (node, output, state) => {
let code;
if (node.lang && highlightjs.getLanguage(node.lang)) {
code = highlightjs.highlight(node.content, { language: node.lang, ignoreIllegals: true });
}
return getHTML('pre', getHTML(
'code', code ? code.value : markdown.sanitizeText(node.content), { class: `hljs ${code ? 'language-' + code.language : ''}` }, state
), { class: 'd-code' }, state);
}
}),
inlineCode: Object.assign({}, markdown.defaultRules.inlineCode, {
match: source => markdown.defaultRules.inlineCode.match.regex.exec(source),
html: (node, output, state) => {
return getHTML('code', markdown.sanitizeText(node.content.trim()), {class: 'd-inline-code'});
}
}),
// Spoilers
spoilers: {
order: 0,
match: source => /^\|\|([\s\S]+?)\|\|/.exec(source),
parse: (capture, parse, state) => {
return {
content: parse(capture[1], state)
};
},
html: (node, output, state) => {
return getHTML('span', output(node.content, state), { class: 'd-spoiler' }, state);
}
},
};
const embeded = {
link: Object.assign({}, markdown.defaultRules.link, {
match: inlineRegex(patterns.link),
html: (node, output, state) => { return getHTML('a', output(node.content, state), { class: 'd-masked-link', href: node.target }); }
}),
autolink: Object.assign({}, markdown.defaultRules.autolink, {
match: inlineRegex(patterns.autolink),
parse: (capture) => {
return {
content: capture[1]
};
},
html: (node, output, state) => { return getHTML('a', node.content, { class: 'd-auto-link', href: node.content }); }
}),
};
/**
* Parses provided source to HTML.
* @param {String} tag Wrapping HTML tag.
* @param {String} content Content of element.
* @param {Object} attributes Attributes of element.
* @param {Boolean} [closed=true] Set to false if element is single tag.
* @returns {String}
*/
const getHTML = (tag, content, attributes = {}, closed = true) => {
let attribute = '';
for (const key in attributes) {
if (Object.hasOwnProperty.call(attributes, key) && attributes[key]) {
attribute += ` ${markdown.sanitizeText(key)}="${markdown.sanitizeText(attributes[key])}"`;
}
}
const element = closed ? `<${tag}${attribute}>${content}</${tag}>` : `<${tag}${attribute}>`;
return element;
};
/**
* Parses provided array of nested HTML into one string.
* @param {Array} items Items to be converted.
* @param {Object} options Options for parser.
* @param {String} options.type List parent type.
* @param {Object} options.classes Custom classes.
* @param {Object} options.classes.item Custom class for items.
* @param {Object} options.classes.list Custom class for lists.
* @returns {String}
*/
const getNestedHTML = (items, options = {
type: 'ul',
classes: {
item: '',
list: '',
}
}) => {
const { type, classes } = options;
let result = '';
for (let i = 0; i < items.length; i++) {
const element = items[i];
const has_children = element.items ? element.items.length > 0 : false;
if (has_children) {
result += getHTML('li', element.text + getHTML(type, getNestedHTML(element.items, options), {class: classes.list}), {class: classes.item});
} else {
result += getHTML('li', element.text, {class: classes.item});
}
}
return result;
};
/**
* Parses provided source to HTML.
* @param {String} source Markdown to be converted.
* @param {Object} [options] Options for parser.
* @param {Boolean} [options.embed=true] If links should be embeded.
* @param {Boolean} [options.includeDefault=true] If default rules are to be used.
* @param {Object} [state] Simplemarkdown state object.
* @param {Boolean} [state.inline=false] Simplemarkdown inline setting.
* @param {Boolean} [state.disableAutoBlockNewlines=true] Simplemarkdown disableAutoBlockNewLines setting.
* @param {Object} [state.mentions] Object for discord mention functions.
* @param {Function} [state.mentions.user] Simplemarkdown state object.
* @param {Function} [state.mentions.channel] Simplemarkdown state object.
* @param {Function} [state.mentions.role] Simplemarkdown state object.
* @param {Function} [state.mentions.everyone] Simplemarkdown state object.
* @param {Function} [state.mentions.here] Simplemarkdown state object.
* @param {Array<{
* name: String,
* match: Function,
* parse: Function,
* react?: Function,
* html?: Function,
* }>} [extensions] Rule extensions for parser.
*/
export const render = (
source,
options = { includeDefault: true, embed: true },
state = {},
extensions = []
) => {
let { includeDefault, embed } = options;
const _rules = {};
const _state = {
inline: false,
disableAutoBlockNewlines: true,
mentions: {
user: node => '@' + markdown.sanitizeText(node.id),
channel: node => '#' + markdown.sanitizeText(node.id),
role: node => '@' + markdown.sanitizeText(node.id),
everyone: () => '@everyone',
here: () => '@here'
},
};
if (includeDefault == null) {
includeDefault = true;
}
if (embed == null) {
embed = true;
}
if (includeDefault) {
Object.assign(_rules, rules);
}
if (embed) {
Object.assign(_rules, embeded);
}
for (let i = 0; i < extensions.length; i++) {
const extension = extensions[i];
const obj = {};
obj[extension.name] = {
match: extension.match,
parse: extension.parse,
react: extension.react,
html: extension.html
};
Object.defineProperties(_rules, extension);
}
const parser = markdown.parserFor(_rules);
const renderer = markdown.outputFor(_rules, 'html');
return renderer(parser(source, _state), _state);
};
const export_obj = {
/**
* Installs plugin as globalProperty for Vue
*/
install: (app, options = {
inject_instances: false,
inject_parsers: false,
}) => {
const { globalProperties } = app.config;
const definePropertyOptions = { writable: false, readable: true };
Object.defineProperty(globalProperties, '$md_render', { value: render, ...definePropertyOptions });
if (options.inject_instances) {
Object.defineProperty(globalProperties, '$simple_markdown', { value: markdown, ...definePropertyOptions });
Object.defineProperty(globalProperties, '$highlightjs', { value: highlightjs, ...definePropertyOptions });
}
if (options.inject_parsers) {
Object.defineProperty(globalProperties, '$getNestedHTML', { value: getNestedHTML, ...definePropertyOptions });
Object.defineProperty(globalProperties, '$getHTML', { value: getHTML, ...definePropertyOptions });
}
},
/**
* Parses provided source into HTML
*/
render,
getNestedHTML,
getHTML,
};
export default export_obj;
;