renderer.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. 'use strict';
  2. const marked = require('marked');
  3. let JSDOM,
  4. createDOMPurify;
  5. const { escape } = require('marked/src/helpers');
  6. const { encodeURL, slugize, stripHTML, url_for, isExternalLink } = require('hexo-util');
  7. const MarkedRenderer = marked.Renderer;
  8. const MarkedTokenizer = marked.Tokenizer;
  9. const { basename, dirname, extname, join } = require('path').posix;
  10. const rATag = /<a(?:\s+?|\s+?[^<>]+\s+?)?href=["'](?:#)([^<>"']+)["'][^<>]*>/i;
  11. const rDlSyntax = /(?:^|\s)(\S.+)<br>:\s+(\S.+)/;
  12. const anchorId = (str, transformOption) => {
  13. return slugize(str.trim(), {transform: transformOption});
  14. };
  15. class Renderer extends MarkedRenderer {
  16. constructor(hexo) {
  17. super();
  18. this._headingId = {};
  19. this.hexo = hexo;
  20. }
  21. // Add id attribute to headings
  22. heading(text, level) {
  23. const { anchorAlias, headerIds, modifyAnchors } = this.options;
  24. const { _headingId } = this;
  25. if (!headerIds) {
  26. return `<h${level}>${text}</h${level}>`;
  27. }
  28. const transformOption = modifyAnchors;
  29. let id = anchorId(stripHTML(text), transformOption);
  30. const headingId = _headingId;
  31. const anchorAliasOpt = anchorAlias && text.startsWith('<a href="#');
  32. if (anchorAliasOpt) {
  33. const customAnchor = text.match(rATag)[1];
  34. id = anchorId(stripHTML(customAnchor), transformOption);
  35. }
  36. // Add a number after id if repeated
  37. if (headingId[id]) {
  38. id += `-${headingId[id]++}`;
  39. } else {
  40. headingId[id] = 1;
  41. }
  42. if (anchorAliasOpt) {
  43. text = text.replace(rATag, (str, alias) => {
  44. return str.replace(alias, id);
  45. });
  46. }
  47. // add headerlink
  48. return `<h${level} id="${id}"><a href="#${id}" class="headerlink" title="${stripHTML(text)}"></a>${text}</h${level}>`;
  49. }
  50. link(href, title, text) {
  51. const { external_link, sanitizeUrl } = this.options;
  52. const { url: urlCfg } = this.hexo.config;
  53. if (sanitizeUrl) {
  54. if (href.startsWith('javascript:') || href.startsWith('vbscript:') || href.startsWith('data:')) {
  55. href = '';
  56. }
  57. }
  58. let out = '<a href="';
  59. try {
  60. out += encodeURL(href);
  61. } catch (e) {
  62. out += href;
  63. }
  64. out += '"';
  65. if (title) {
  66. out += ` title="${title}"`;
  67. }
  68. if (external_link) {
  69. const target = ' target="_blank"';
  70. const noopener = ' rel="noopener"';
  71. const nofollowTag = ' rel="noopener external nofollow noreferrer"';
  72. if (isExternalLink(href, urlCfg, external_link.exclude)) {
  73. if (external_link.enable && external_link.nofollow) {
  74. out += target + nofollowTag;
  75. } else if (external_link.enable) {
  76. out += target + noopener;
  77. } else if (external_link.nofollow) {
  78. out += nofollowTag;
  79. }
  80. }
  81. }
  82. out += `>${text}</a>`;
  83. return out;
  84. }
  85. // Support Basic Description Lists
  86. paragraph(text) {
  87. const { descriptionLists = true } = this.options;
  88. if (descriptionLists) {
  89. if (rDlSyntax.test(text)) {
  90. return text.replace(rDlSyntax, '<dl><dt>$1</dt><dd>$2</dd></dl>');
  91. }
  92. }
  93. return `<p>${text}</p>\n`;
  94. }
  95. // Prepend root to image path
  96. image(href, title, text) {
  97. const { hexo, options } = this;
  98. const { relative_link } = hexo.config;
  99. const { lazyload, prependRoot, postPath } = options;
  100. if (!/^(#|\/\/|http(s)?:)/.test(href) && !relative_link && prependRoot) {
  101. if (!href.startsWith('/') && !href.startsWith('\\') && postPath) {
  102. const PostAsset = hexo.model('PostAsset');
  103. // findById requires forward slash
  104. const asset = PostAsset.findById(join(postPath, href.replace(/\\/g, '/')));
  105. // asset.path is backward slash in Windows
  106. if (asset) href = asset.path.replace(/\\/g, '/');
  107. }
  108. href = url_for.call(hexo, href);
  109. }
  110. let out = `<img src="${encodeURL(href)}"`;
  111. if (text) out += ` alt="${text}"`;
  112. if (title) out += ` title="${title}"`;
  113. if (lazyload) out += ' loading="lazy"';
  114. out += '>';
  115. return out;
  116. }
  117. }
  118. marked.setOptions({
  119. langPrefix: ''
  120. });
  121. // https://github.com/markedjs/marked/blob/b6773fca412c339e0cedd56b63f9fa1583cfd372/src/Lexer.js#L8-L24
  122. const smartypants = (str, quotes) => {
  123. const [openDbl, closeDbl, openSgl, closeSgl] = typeof quotes === 'string' && quotes.length === 4
  124. ? quotes
  125. : ['\u201c', '\u201d', '\u2018', '\u2019'];
  126. return str
  127. // em-dashes
  128. .replace(/---/g, '\u2014')
  129. // en-dashes
  130. .replace(/--/g, '\u2013')
  131. // opening singles
  132. .replace(/(^|[-\u2014/([{"\s])'/g, '$1' + openSgl)
  133. // closing singles & apostrophes
  134. .replace(/'/g, closeSgl)
  135. // opening doubles
  136. .replace(/(^|[-\u2014/([{\u2018\s])"/g, '$1' + openDbl)
  137. // closing doubles
  138. .replace(/"/g, closeDbl)
  139. // ellipses
  140. .replace(/\.{3}/g, '\u2026');
  141. };
  142. class Tokenizer extends MarkedTokenizer {
  143. // Support AutoLink option
  144. // https://github.com/markedjs/marked/blob/b6773fca412c339e0cedd56b63f9fa1583cfd372/src/Tokenizer.js#L606-L641
  145. url(src, mangle) {
  146. const { options, rules } = this;
  147. const { mangle: isMangle, autolink } = options;
  148. if (!autolink) return;
  149. const cap = rules.inline.url.exec(src);
  150. if (cap) {
  151. let text, href;
  152. if (cap[2] === '@') {
  153. text = escape(isMangle ? mangle(cap[0]) : cap[0]);
  154. href = 'mailto:' + text;
  155. } else {
  156. // do extended autolink path validation
  157. let prevCapZero;
  158. do {
  159. prevCapZero = cap[0];
  160. cap[0] = rules.inline._backpedal.exec(cap[0])[0];
  161. } while (prevCapZero !== cap[0]);
  162. text = escape(cap[0]);
  163. if (cap[1] === 'www.') {
  164. href = 'http://' + text;
  165. } else {
  166. href = text;
  167. }
  168. }
  169. return {
  170. type: 'link',
  171. raw: cap[0],
  172. text,
  173. href,
  174. tokens: [
  175. {
  176. type: 'text',
  177. raw: text,
  178. text
  179. }
  180. ]
  181. };
  182. }
  183. }
  184. // Override smartypants
  185. inlineText(src, inRawBlock) {
  186. const { options, rules } = this;
  187. const { quotes, smartypants: isSmarty } = options;
  188. // https://github.com/markedjs/marked/blob/b6773fca412c339e0cedd56b63f9fa1583cfd372/src/Tokenizer.js#L643-L658
  189. const cap = rules.inline.text.exec(src);
  190. if (cap) {
  191. let text;
  192. if (inRawBlock) {
  193. text = cap[0];
  194. } else {
  195. text = escape(isSmarty ? smartypants(cap[0], quotes) : cap[0]);
  196. }
  197. return {
  198. type: 'text',
  199. raw: cap[0],
  200. text
  201. };
  202. }
  203. }
  204. }
  205. module.exports = function(data, options) {
  206. const { post_asset_folder, marked: markedCfg, source_dir } = this.config;
  207. const { prependRoot, postAsset, dompurify } = markedCfg;
  208. const { path, text } = data;
  209. // exec filter to extend renderer.
  210. const renderer = new Renderer(this);
  211. this.execFilterSync('marked:renderer', renderer, {context: this});
  212. const tokenizer = new Tokenizer();
  213. this.execFilterSync('marked:tokenizer', tokenizer, {context: this});
  214. let postPath = '';
  215. if (path && post_asset_folder && prependRoot && postAsset) {
  216. const Post = this.model('Post');
  217. // Windows compatibility, Post.findOne() requires forward slash
  218. const source = path.substring(this.source_dir.length).replace(/\\/g, '/');
  219. const post = Post.findOne({ source });
  220. if (post) {
  221. const { source: postSource } = post;
  222. postPath = join(source_dir, dirname(postSource), basename(postSource, extname(postSource)));
  223. }
  224. }
  225. let sanitizer = function(html) { return html; };
  226. if (dompurify) {
  227. if (createDOMPurify === undefined && JSDOM === undefined) {
  228. createDOMPurify = require('dompurify');
  229. JSDOM = require('jsdom').JSDOM;
  230. }
  231. const window = new JSDOM('').window;
  232. const DOMPurify = createDOMPurify(window);
  233. let param = {};
  234. if (dompurify !== true) {
  235. param = dompurify;
  236. }
  237. sanitizer = function(html) { return DOMPurify.sanitize(html, param); };
  238. }
  239. return sanitizer(marked(text, Object.assign({
  240. renderer,
  241. tokenizer
  242. }, markedCfg, options, { postPath })));
  243. };