hexo-renderer-marked
Version:
Markdown renderer plugin for Hexo
284 lines (238 loc) • 8.3 kB
JavaScript
const { marked } = require('marked');
let JSDOM,
createDOMPurify;
const { encodeURL, slugize, stripHTML, url_for, isExternalLink, escapeHTML: escape, unescapeHTML: unescape } = require('hexo-util');
const { basename, dirname, extname, join } = require('path').posix;
const rATag = /<a(?:\s+?|\s+?[^<>]+\s+?)?href=["'](?:#)([^<>"']+)["'][^<>]*>/i;
const rDlSyntax = /(?:^|\s)(\S.+)<br>:\s+(\S.+)/;
const anchorId = (str, transformOption) => {
return slugize(stripHTML(unescape(str)).trim(), { transform: transformOption });
};
function mangleEmail(text) {
let out = '';
let i,
ch;
const l = text.length;
for (i = 0; i < l; i++) {
ch = text.charCodeAt(i);
if (Math.random() > 0.5) {
ch = 'x' + ch.toString(16);
}
out += '&#' + ch + ';';
}
return out;
}
const renderer = {
// Add id attribute to headings
heading({ tokens, depth: level }) {
let text = this.parser.parseInline(tokens);
const { anchorAlias, headerIds, modifyAnchors, _headingId } = this.options;
if (!headerIds) {
return `<h${level}>${text}</h${level}>`;
}
const transformOption = modifyAnchors;
let id = anchorId(text, transformOption);
const headingId = _headingId;
const anchorAliasOpt = anchorAlias && text.startsWith('<a href="#');
if (anchorAliasOpt) {
const customAnchor = text.match(rATag)[1];
id = anchorId(customAnchor, transformOption);
}
// Add a number after id if repeated
if (headingId[id]) {
id += `-${headingId[id]++}`;
} else {
headingId[id] = 1;
}
if (anchorAliasOpt) {
text = text.replace(rATag, (str, alias) => {
return str.replace(alias, id);
});
}
// add headerlink
return `<h${level} id="${id}"><a href="#${id}" class="headerlink" title="${stripHTML(text)}"></a>${text}</h${level}>`;
},
link({ tokens, href, title }) {
const text = this.parser.parseInline(tokens);
const { external_link, sanitizeUrl, hexo, mangle } = this.options;
const { url: urlCfg } = hexo.config;
if (sanitizeUrl) {
if (href.startsWith('javascript:') || href.startsWith('vbscript:') || href.startsWith('data:')) {
href = '';
}
}
if (mangle) {
if (href.startsWith('mailto:')) {
const email = href.substring(7);
const mangledEmail = mangleEmail(email);
href = `mailto:${mangledEmail}`;
}
}
let out = '<a href="';
try {
out += encodeURL(href);
} catch (e) {
out += href;
}
out += '"';
if (title) {
out += ` title="${escape(title)}"`;
}
if (external_link) {
const target = ' target="_blank"';
const noopener = ' rel="noopener"';
const nofollowTag = ' rel="noopener external nofollow noreferrer"';
if (isExternalLink(href, urlCfg, external_link.exclude)) {
if (external_link.enable && external_link.nofollow) {
out += target + nofollowTag;
} else if (external_link.enable) {
out += target + noopener;
} else if (external_link.nofollow) {
out += nofollowTag;
}
}
}
out += `>${text}</a>`;
return out;
},
// Support Basic Description Lists
paragraph({ tokens }) {
const text = this.parser.parseInline(tokens);
const { descriptionLists = true } = this.options;
if (descriptionLists && text.includes('<br>:')) {
if (rDlSyntax.test(text)) {
return text.replace(rDlSyntax, '<dl><dt>$1</dt><dd>$2</dd></dl>');
}
}
return `<p>${text}</p>\n`;
},
// Prepend root to image path
image({ href, title, text }) {
const { options } = this;
const { hexo } = options;
const { relative_link } = hexo.config;
const { lazyload, figcaption, prependRoot, postPath } = options;
if (!/^(#|\/\/|http(s)?:)/.test(href) && !relative_link && prependRoot) {
if (!href.startsWith('/') && !href.startsWith('\\') && postPath) {
const PostAsset = hexo.model('PostAsset');
// findById requires forward slash
const asset = PostAsset.findById(join(postPath, href.replace(/\\/g, '/')));
// asset.path is backward slash in Windows
if (asset) href = asset.path.replace(/\\/g, '/');
}
href = url_for.call(hexo, href);
}
let out = `<img src="${encodeURL(href)}"`;
if (text) out += ` alt="${escape(text)}"`;
if (title) out += ` title="${escape(title)}"`;
if (lazyload) out += ' loading="lazy"';
out += '>';
if (figcaption && text) {
return `<figure>${out}<figcaption aria-hidden="true">${text}</figcaption></figure>`;
}
return out;
}
};
// https://github.com/markedjs/marked/blob/b6773fca412c339e0cedd56b63f9fa1583cfd372/src/Lexer.js#L8-L24
const smartypants = (str, quotes) => {
const [openDbl, closeDbl, openSgl, closeSgl] = typeof quotes === 'string' && quotes.length === 4
? quotes
: ['\u201c', '\u201d', '\u2018', '\u2019'];
return str
// em-dashes
.replace(/---/g, '\u2014')
// en-dashes
.replace(/--/g, '\u2013')
// opening singles
.replace(/(^|[-\u2014/([{"\s])'/g, '$1' + openSgl)
// closing singles & apostrophes
.replace(/'/g, closeSgl)
// opening doubles
.replace(/(^|[-\u2014/([{\u2018\s])"/g, '$1' + openDbl)
// closing doubles
.replace(/"/g, closeDbl)
// ellipses
.replace(/\.{3}/g, '\u2026');
};
const tokenizer = {
// Support autolink option
url(src) {
const { autolink } = this.options;
if (!autolink) return;
// return false to use original url tokenizer
return false;
},
// Override smartypants
inlineText(src) {
const { options, rules } = this;
const { quotes, smartypants: isSmarty } = options;
// https://github.com/markedjs/marked/blob/b6773fca412c339e0cedd56b63f9fa1583cfd372/src/Tokenizer.js#L643-L658
const cap = rules.inline.text.exec(src);
if (cap) {
let text;
if (this.lexer.state.inRawBlock || this.rules.inline.url.exec(src)) {
text = cap[0];
} else {
text = escape(isSmarty ? smartypants(cap[0], quotes) : cap[0]);
}
return {
type: 'text',
raw: cap[0],
text
};
}
}
};
module.exports = function(data, options) {
const { post_asset_folder, marked: markedCfg, source_dir } = this.config;
const { prependRoot, postAsset, dompurify } = markedCfg;
const { path, text } = data;
marked.defaults.extensions = null;
marked.defaults.tokenizer = null;
marked.defaults.renderer = null;
marked.defaults.hooks = null;
marked.defaults.walkTokens = null;
// exec filter to extend marked
this.execFilterSync('marked:use', marked.use, { context: this });
// exec filter to extend renderer
this.execFilterSync('marked:renderer', renderer, { context: this });
// exec filter to extend tokenizer
this.execFilterSync('marked:tokenizer', tokenizer, { context: this });
const extensions = [];
this.execFilterSync('marked:extensions', extensions, { context: this });
marked.use({ extensions });
let postPath = '';
if (path && post_asset_folder && prependRoot && postAsset) {
const Post = this.model('Post');
// Windows compatibility, Post.findOne() requires forward slash
const source = path.substring(this.source_dir.length).replace(/\\/g, '/');
const post = Post.findOne({ source });
if (post) {
const { source: postSource } = post;
postPath = join(source_dir, dirname(postSource), basename(postSource, extname(postSource)));
}
}
let sanitizer = function(html) { return html; };
if (dompurify) {
if (createDOMPurify === undefined && JSDOM === undefined) {
createDOMPurify = require('dompurify');
JSDOM = require('jsdom').JSDOM;
}
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
let param = {};
if (dompurify !== true) {
param = dompurify;
}
sanitizer = function(html) { return DOMPurify.sanitize(html, param); };
}
marked.use({
renderer,
tokenizer
});
return sanitizer(marked.parse(text, Object.assign({
// headerIds was removed in marked v8.0.0, but we still need it
headerIds: true
}, markedCfg, options, { postPath, hexo: this, _headingId: {} })));
};
;