You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

425 lines
16 KiB

9 months ago
1 year ago
1 year ago
1 year ago
1 year ago
3 years ago
3 years ago
  1. define([
  2. 'jquery',
  3. '/api/config',
  4. '/bower_components/marked/marked.min.js',
  5. '/common/common-hash.js',
  6. '/common/common-util.js',
  7. '/common/hyperscript.js',
  8. '/common/media-tag.js',
  9. '/common/highlight/highlight.pack.js',
  10. '/customize/messages.js',
  11. '/bower_components/diff-dom/diffDOM.js',
  12. '/bower_components/tweetnacl/nacl-fast.min.js',
  13. 'css!/common/highlight/styles/github.css'
  14. ],function ($, ApiConfig, Marked, Hash, Util, h, MediaTag, Highlight, Messages) {
  15. var DiffMd = {};
  16. var DiffDOM = window.diffDOM;
  17. var renderer = new Marked.Renderer();
  18. var restrictedRenderer = new Marked.Renderer();
  19. var Mermaid = {
  20. init: function () {}
  21. };
  22. require(['mermaid', 'css!/code/mermaid-new.css'], function (_Mermaid) {
  23. console.log(arguments);
  24. Mermaid = _Mermaid;
  25. });
  26. var highlighter = function () {
  27. return function(code, lang) {
  28. if (lang) {
  29. try {
  30. return Highlight.highlight(lang, code).value;
  31. } catch (e) {
  32. return code;
  33. }
  34. }
  35. return code;
  36. };
  37. };
  38. Marked.setOptions({
  39. //sanitize: true, // Disable HTML
  40. renderer: renderer,
  41. highlight: highlighter(),
  42. });
  43. var toc = [];
  44. var getTOC = function () {
  45. var content = [h('h2', Messages.markdown_toc)];
  46. toc.forEach(function (obj) {
  47. // Only include level 2 headings
  48. var level = obj.level - 1;
  49. if (level < 1) { return; }
  50. var a = h('a.cp-md-toc-link', {
  51. href: '#',
  52. 'data-href': obj.id,
  53. });
  54. a.innerHTML = obj.title;
  55. content.push(h('p.cp-md-toc-'+level, ['• ', a]));
  56. });
  57. return h('div.cp-md-toc', content).outerHTML;
  58. };
  59. DiffMd.render = function (md, sanitize, restrictedMd) {
  60. Marked.setOptions({
  61. renderer: restrictedMd ? restrictedRenderer : renderer,
  62. });
  63. var r = Marked(md, {
  64. sanitize: sanitize
  65. });
  66. // Add Table of Content
  67. if (!restrictedMd) {
  68. r = r.replace(/<div class="cp-md-toc"><\/div>/g, getTOC());
  69. }
  70. toc = [];
  71. return r;
  72. };
  73. var mediaMap = {};
  74. var defaultCode = renderer.code;
  75. renderer.code = function (code, language) {
  76. if (language === 'mermaid' && code.match(/^(graph|pie|gantt|sequenceDiagram|classDiagram|gitGraph)/)) {
  77. return '<pre class="mermaid">'+code+'</pre>';
  78. } else {
  79. return defaultCode.apply(renderer, arguments);
  80. }
  81. };
  82. restrictedRenderer.code = renderer.code;
  83. renderer.heading = function (text, level) {
  84. var i = 0;
  85. var safeText = text.toLowerCase().replace(/[^\w]+/g, '-');
  86. var getId = function () {
  87. return 'cp-md-' + i + '-' + safeText;
  88. };
  89. var id = getId();
  90. var isAlreadyUsed = function (obj) { return obj.id === id; };
  91. while (toc.some(isAlreadyUsed)) {
  92. i++;
  93. id = getId();
  94. }
  95. toc.push({
  96. level: level,
  97. id: id,
  98. title: Util.stripTags(text)
  99. });
  100. return "<h" + level + " id=\"" + id + "\"><a href=\"#" + id + "\" class=\"anchor\"></a>" + text + "</h" + level + ">";
  101. };
  102. restrictedRenderer.heading = function (text) {
  103. return text;
  104. };
  105. // Tasks list
  106. var checkedTaskItemPtn = /^\s*(<p>)?\[[xX]\](<\/p>)?\s*/;
  107. var uncheckedTaskItemPtn = /^\s*(<p>)?\[ ?\](<\/p>)?\s*/;
  108. var bogusCheckPtn = /<input checked="" disabled="" type="checkbox">/;
  109. var bogusUncheckPtn = /<input disabled="" type="checkbox">/;
  110. renderer.listitem = function (text) {
  111. var isCheckedTaskItem = checkedTaskItemPtn.test(text);
  112. var isUncheckedTaskItem = uncheckedTaskItemPtn.test(text);
  113. var hasBogusCheckedInput = bogusCheckPtn.test(text);
  114. var hasBogusUncheckedInput = bogusUncheckPtn.test(text);
  115. var isCheckbox = true;
  116. if (isCheckedTaskItem) {
  117. text = text.replace(checkedTaskItemPtn,
  118. '<i class="fa fa-check-square" aria-hidden="true"></i>') + '\n';
  119. } else if (isUncheckedTaskItem) {
  120. text = text.replace(uncheckedTaskItemPtn,
  121. '<i class="fa fa-square-o" aria-hidden="true"></i>') + '\n';
  122. } else if (hasBogusCheckedInput) {
  123. text = text.replace(bogusCheckPtn,
  124. '<i class="fa fa-check-square" aria-hidden="true"></i>') + '\n';
  125. } else if (hasBogusUncheckedInput) {
  126. text = text.replace(bogusUncheckPtn,
  127. '<i class="fa fa-square-o" aria-hidden="true"></i>') + '\n';
  128. } else {
  129. isCheckbox = false;
  130. }
  131. var cls = (isCheckbox) ? ' class="todo-list-item"' : '';
  132. return '<li'+ cls + '>' + text + '</li>\n';
  133. };
  134. restrictedRenderer.listitem = function (text) {
  135. if (bogusCheckPtn.test(text)) {
  136. text = text.replace(bogusCheckPtn, '');
  137. }
  138. return '<li>' + text + '</li>\n';
  139. };
  140. renderer.image = function (href, title, text) {
  141. if (href.slice(0,6) === '/file/') {
  142. // DEPRECATED
  143. // Mediatag using markdown syntax should not be used anymore so they don't support
  144. // password-protected files
  145. console.log('DEPRECATED: mediatag using markdown syntax!');
  146. var parsed = Hash.parsePadUrl(href);
  147. var secret = Hash.getSecrets('file', parsed.hash);
  148. var src = (ApiConfig.fileHost || '') +Hash.getBlobPathFromHex(secret.channel);
  149. var key = Hash.encodeBase64(secret.keys.cryptKey);
  150. var mt = '<media-tag src="' + src + '" data-crypto-key="cryptpad:' + key + '"></media-tag>';
  151. if (mediaMap[src]) {
  152. mt += mediaMap[src];
  153. }
  154. mt += '</media-tag>';
  155. return mt;
  156. }
  157. var out = '<img src="' + href + '" alt="' + text + '"';
  158. if (title) {
  159. out += ' title="' + title + '"';
  160. }
  161. out += this.options.xhtml ? '/>' : '>';
  162. return out;
  163. };
  164. restrictedRenderer.image = renderer.image;
  165. var renderParagraph = function (p) {
  166. return /<media\-tag[\s\S]*>/i.test(p)? p + '\n': '<p>' + p + '</p>\n';
  167. };
  168. renderer.paragraph = function (p) {
  169. if (p === '[TOC]') {
  170. return '<p><div class="cp-md-toc"></div></p>';
  171. }
  172. return renderParagraph(p);
  173. };
  174. restrictedRenderer.paragraph = function (p) {
  175. return renderParagraph(p);
  176. };
  177. var MutationObserver = window.MutationObserver;
  178. var forbiddenTags = [
  179. 'SCRIPT',
  180. 'IFRAME',
  181. 'OBJECT',
  182. 'APPLET',
  183. 'VIDEO', // privacy implications of videos are the same as images
  184. 'AUDIO', // same with audio
  185. ];
  186. var unsafeTag = function (info) {
  187. /*if (info.node && $(info.node).parents('media-tag').length) {
  188. // Do not remove elements inside a media-tag
  189. return true;
  190. }*/
  191. if (['addAttribute', 'modifyAttribute'].indexOf(info.diff.action) !== -1) {
  192. if (/^on/i.test(info.diff.name)) {
  193. console.log("Rejecting forbidden element attribute with name", info.diff.name);
  194. return true;
  195. }
  196. }
  197. if (['addElement', 'replaceElement'].indexOf(info.diff.action) !== -1) {
  198. var msg = "Rejecting forbidden tag of type (%s)";
  199. if (info.diff.element && forbiddenTags.indexOf(info.diff.element.nodeName.toUpperCase()) !== -1) {
  200. console.log(msg, info.diff.element.nodeName);
  201. return true;
  202. } else if (info.diff.newValue && forbiddenTags.indexOf(info.diff.newValue.nodeName.toUpperCase()) !== -1) {
  203. console.log("Replacing restricted element type (%s) with PRE", info.diff.newValue.nodeName);
  204. info.diff.newValue.nodeName = 'PRE';
  205. }
  206. }
  207. };
  208. var slice = function (coll) {
  209. return Array.prototype.slice.call(coll);
  210. };
  211. var removeNode = function (node) {
  212. if (!(node && node.parentElement)) { return; }
  213. var parent = node.parentElement;
  214. if (!parent) { return; }
  215. console.log('removing %s tag', node.nodeName);
  216. parent.removeChild(node);
  217. };
  218. var removeForbiddenTags = function (root) {
  219. if (!root) { return; }
  220. if (forbiddenTags.indexOf(root.nodeName.toUpperCase()) !== -1) { removeNode(root); }
  221. slice(root.children).forEach(removeForbiddenTags);
  222. };
  223. /* remove listeners from the DOM */
  224. var removeListeners = function (root) {
  225. if (!root) { return; }
  226. slice(root.attributes).map(function (attr) {
  227. if (/^on/i.test(attr.name)) {
  228. console.log('removing attribute', attr.name, root.attributes[attr.name]);
  229. root.attributes.removeNamedItem(attr.name);
  230. }
  231. });
  232. // all the way down
  233. slice(root.children).forEach(removeListeners);
  234. };
  235. var domFromHTML = function (html) {
  236. var Dom = new DOMParser().parseFromString(html, "text/html");
  237. Dom.normalize();
  238. removeForbiddenTags(Dom.body);
  239. removeListeners(Dom.body);
  240. return Dom;
  241. };
  242. var DD = new DiffDOM({
  243. preDiffApply: function (info) {
  244. if (unsafeTag(info)) { return true; }
  245. },
  246. });
  247. var makeDiff = function (A, B, id) {
  248. var Err;
  249. var Els = [A, B].map(function (frag) {
  250. if (typeof(frag) === 'object') {
  251. if (!frag || (frag && !frag.body)) {
  252. Err = "No body";
  253. return;
  254. }
  255. var els = frag.body.querySelectorAll('#'+id);
  256. if (els.length) {
  257. return els[0];
  258. }
  259. }
  260. Err = 'No candidate found';
  261. });
  262. if (Err) { return Err; }
  263. var patch = DD.diff(Els[0], Els[1]);
  264. return patch;
  265. };
  266. DiffMd.apply = function (newHtml, $content, common) {
  267. var contextMenu = common.importMediaTagMenu();
  268. var id = $content.attr('id');
  269. if (!id) { throw new Error("The element must have a valid id"); }
  270. var pattern = /(<media-tag src="([^"]*)" data-crypto-key="([^"]*)">)<\/media-tag>/g;
  271. var unsafe_newHtmlFixed = newHtml.replace(pattern, function (all, tag, src) {
  272. var mt = tag;
  273. if (mediaMap[src]) { mt += mediaMap[src]; }
  274. return mt + '</media-tag>';
  275. });
  276. var newDomFixed = domFromHTML(unsafe_newHtmlFixed);
  277. if (!newDomFixed || !newDomFixed.body) { return; }
  278. var safe_newHtmlFixed = newDomFixed.body.outerHTML;
  279. var $div = $('<div>', {id: id}).append(safe_newHtmlFixed);
  280. var Dom = domFromHTML($('<div>').append($div).html());
  281. $content[0].normalize();
  282. var mermaid_source = [];
  283. var mermaid_cache = {};
  284. var canonicalizeMermaidSource = function (src) {
  285. // ignore changes to empty lines, since that won't affect
  286. // since it will have no effect on the rendered charts
  287. return src.replace(/\n[ \t]*\n*[ \t]*\n/g, '\n');
  288. };
  289. // iterate over the unrendered mermaid inputs, caching their source as you go
  290. $(newDomFixed).find('pre.mermaid').each(function (index, el) {
  291. if (el.childNodes.length === 1 && el.childNodes[0].nodeType === 3) {
  292. var src = canonicalizeMermaidSource(el.childNodes[0].wholeText);
  293. el.setAttribute('mermaid-source', src);
  294. mermaid_source[index] = src;
  295. }
  296. });
  297. // remember the previous scroll position
  298. var $parent = $content.parent();
  299. var scrollTop = $parent.scrollTop();
  300. // iterate over rendered mermaid charts
  301. $content.find('pre.mermaid:not([processed="true"])').each(function (index, el) {
  302. // retrieve the attached source code which it was drawn
  303. var src = el.getAttribute('mermaid-source');
  304. // check if that source exists in the set of charts which are about to be rendered
  305. if (mermaid_source.indexOf(src) === -1) {
  306. // if it's not, then you can remove it
  307. if (el.parentNode && el.parentNode.children.length) {
  308. el.parentNode.removeChild(el);
  309. }
  310. } else if (el.childNodes.length === 1 && el.childNodes[0].nodeType !== 3) {
  311. // otherwise, confirm that the content of the rendered chart is not a text node
  312. // and keep a copy of it
  313. mermaid_cache[src] = el.childNodes[0];
  314. }
  315. });
  316. var oldDom = domFromHTML($content[0].outerHTML);
  317. var patch = makeDiff(oldDom, Dom, id);
  318. if (typeof(patch) === 'string') {
  319. throw new Error(patch);
  320. } else {
  321. DD.apply($content[0], patch);
  322. var $mts = $content.find('media-tag:not(:has(*))');
  323. $mts.each(function (i, el) {
  324. $(el).contextmenu(function (e) {
  325. e.preventDefault();
  326. $(contextMenu.menu).data('mediatag', $(el));
  327. contextMenu.show(e);
  328. });
  329. MediaTag(el);
  330. var observer = new MutationObserver(function(mutations) {
  331. mutations.forEach(function(mutation) {
  332. if (mutation.type === 'childList') {
  333. var list_values = [].slice.call(mutation.target.children)
  334. .map(function (el) { return el.outerHTML; })
  335. .join('');
  336. mediaMap[mutation.target.getAttribute('src')] = list_values;
  337. observer.disconnect();
  338. }
  339. });
  340. });
  341. observer.observe(el, {
  342. attributes: false,
  343. childList: true,
  344. characterData: false
  345. });
  346. });
  347. // Fix Table of contents links
  348. $content.find('a.cp-md-toc-link').off('click').click(function (e) {
  349. e.preventDefault();
  350. e.stopPropagation();
  351. var $a = $(this);
  352. if (!$a.attr('data-href')) { return; }
  353. var target = document.getElementById($a.attr('data-href'));
  354. if (target) { target.scrollIntoView(); }
  355. });
  356. // loop over mermaid elements in the rendered content
  357. $content.find('pre.mermaid').each(function (index, el) {
  358. // since you've simply drawn the content that was supplied via markdown
  359. // you can assume that the index of your rendered charts matches that
  360. // of those in the markdown source.
  361. var src = mermaid_source[index];
  362. el.setAttribute('mermaid-source', src);
  363. var cached = mermaid_cache[src];
  364. // check if you had cached a pre-rendered instance of the supplied source
  365. if (typeof(cached) !== 'object') {
  366. try {
  367. Mermaid.init(undefined, $(el));
  368. } catch (e) { console.error(e); }
  369. return;
  370. }
  371. // if there's a cached rendering, empty out the contained source code
  372. // which would otherwise be drawn again.
  373. // apparently this is the fastest way to empty out an element
  374. while (el.firstChild) { el.removeChild(el.firstChild); } //el.innerHTML = '';
  375. // insert the cached graph
  376. el.appendChild(cached);
  377. // and set a flag indicating that this graph need not be reprocessed
  378. el.setAttribute('data-processed', true);
  379. });
  380. }
  381. // recover the previous scroll position to avoid jank
  382. $parent.scrollTop(scrollTop);
  383. };
  384. return DiffMd;
  385. });