|
@@ -1254,63 +1254,189 @@
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * Escape-first minimal markdown renderer. All HTML in the source is escaped
|
|
|
|
|
- * before formatting is applied, so untrusted input cannot inject markup.
|
|
|
|
|
- * Supports headings, bold, italic, inline code, fenced code blocks, links,
|
|
|
|
|
- * lists, blockquotes, horizontal rules and paragraphs.
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Neutralise unsafe link targets: relative URLs (no scheme) and the
|
|
|
|
|
+ * http/https/mailto/ftp schemes pass through unchanged; any other scheme
|
|
|
|
|
+ * (e.g. `javascript:`) is replaced with `#`. The URL has already been
|
|
|
|
|
+ * HTML-escaped by {@link renderMarkdown}.
|
|
|
|
|
+ * @param {string} url
|
|
|
|
|
+ * @returns {string}
|
|
|
|
|
+ */
|
|
|
|
|
+ function safeMarkdownUrl(url) {
|
|
|
|
|
+ var u = url.trim();
|
|
|
|
|
+ var m = u.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
|
|
|
|
|
+ if (!m) return u;
|
|
|
|
|
+ var scheme = m[1].toLowerCase();
|
|
|
|
|
+ if (scheme === 'http' || scheme === 'https' || scheme === 'mailto' || scheme === 'ftp') return u;
|
|
|
|
|
+ return '#';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Rewrite `[label](url)` links to anchors. The URL is scanned with
|
|
|
|
|
+ * parenthesis depth counting (so one level of balanced parentheses is
|
|
|
|
|
+ * allowed inside it; the scan is linear, immune to regex backtracking)
|
|
|
|
|
+ * and passed through {@link safeMarkdownUrl}. Malformed or unbalanced
|
|
|
|
|
+ * constructs are left as plain text.
|
|
|
|
|
+ * @param {string} text
|
|
|
|
|
+ * @returns {string}
|
|
|
|
|
+ */
|
|
|
|
|
+ function mdLinks(text) {
|
|
|
|
|
+ var result = '';
|
|
|
|
|
+ var i = 0;
|
|
|
|
|
+ while (i < text.length) {
|
|
|
|
|
+ var open = text.indexOf('[', i);
|
|
|
|
|
+ if (open < 0) { result += text.slice(i); break; }
|
|
|
|
|
+ var close = text.indexOf(']', open + 1);
|
|
|
|
|
+ if (close < 0) { result += text.slice(i); break; }
|
|
|
|
|
+ if (close === open + 1 || text.charAt(close + 1) !== '(') {
|
|
|
|
|
+ result += text.slice(i, open + 1);
|
|
|
|
|
+ i = open + 1;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ var depth = 1;
|
|
|
|
|
+ var j = close + 2;
|
|
|
|
|
+ while (j < text.length && depth > 0) {
|
|
|
|
|
+ var ch = text.charAt(j);
|
|
|
|
|
+ if (ch === '(') depth++;
|
|
|
|
|
+ else if (ch === ')') depth--;
|
|
|
|
|
+ if (depth > 0) j++;
|
|
|
|
|
+ }
|
|
|
|
|
+ var url = depth === 0 ? text.slice(close + 2, j) : null;
|
|
|
|
|
+ if (!url || /[\s\u0000]/.test(url)) {
|
|
|
|
|
+ result += text.slice(i, open + 1);
|
|
|
|
|
+ i = open + 1;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ result += text.slice(i, open) +
|
|
|
|
|
+ '<a href="' + safeMarkdownUrl(url) + '">' + text.slice(open + 1, close) + '</a>';
|
|
|
|
|
+ i = j + 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Apply inline markdown formatting (code spans, bold, italic, links) to
|
|
|
|
|
+ * already-escaped text. Code spans are lifted out into placeholders first
|
|
|
|
|
+ * so their contents are never reformatted by the other rules, then
|
|
|
|
|
+ * restored as `<code>` elements.
|
|
|
|
|
+ * @param {string} text
|
|
|
|
|
+ * @returns {string}
|
|
|
|
|
+ */
|
|
|
|
|
+ function mdInline(text) {
|
|
|
|
|
+ var codes = [];
|
|
|
|
|
+ var guarded = text.replace(/`([^`]+)`/g, function (all, code) {
|
|
|
|
|
+ codes.push(code);
|
|
|
|
|
+ return '\u0000' + (codes.length - 1) + '\u0000';
|
|
|
|
|
+ });
|
|
|
|
|
+ guarded = guarded
|
|
|
|
|
+ .replace(/\*\*([^*\s](?:[^*]*[^*\s])?)\*\*/g, '<strong>$1</strong>')
|
|
|
|
|
+ .replace(/\*([^*\s](?:[^*]*[^*\s])?)\*/g, '<em>$1</em>');
|
|
|
|
|
+ guarded = mdLinks(guarded);
|
|
|
|
|
+ return guarded.replace(/\u0000(\d+)\u0000/g, function (all, i) {
|
|
|
|
|
+ return '<code>' + codes[Number(i)] + '</code>';
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Escape-first minimal markdown renderer. All HTML in the source is
|
|
|
|
|
+ * escaped before formatting is applied, so untrusted input cannot inject
|
|
|
|
|
+ * markup. Supports headings (#-######), bold, italic, inline code, fenced
|
|
|
|
|
+ * code blocks (with an optional language class), ordered/unordered lists,
|
|
|
|
|
+ * blockquotes, horizontal rules and paragraphs (blank-line separated;
|
|
|
|
|
+ * single newlines within a paragraph become `<br>`).
|
|
|
|
|
+ * @param {string} raw
|
|
|
|
|
+ * @returns {string} HTML safe for assignment to `innerHTML`.
|
|
|
*/
|
|
*/
|
|
|
function renderMarkdown(raw) {
|
|
function renderMarkdown(raw) {
|
|
|
var escaped = raw
|
|
var escaped = raw
|
|
|
|
|
+ .replace(/\r\n?/g, '\n')
|
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
|
var lines = escaped.split('\n');
|
|
var lines = escaped.split('\n');
|
|
|
var out = [];
|
|
var out = [];
|
|
|
- var inList = false, inCode = false;
|
|
|
|
|
-
|
|
|
|
|
- function closeList() { if (inList) { out.push(inList === 'ul' ? '</ul>' : '</ol>'); inList = false; } }
|
|
|
|
|
|
|
+ var paragraph = []; // buffered plain lines, flushed as one <p> (<br>-joined)
|
|
|
|
|
+ var quote = []; // buffered blockquote lines, flushed as one <blockquote>
|
|
|
|
|
+ var inList = false; // false | 'ul' | 'ol'
|
|
|
|
|
+ var inCode = false;
|
|
|
|
|
+ var codeLang = '';
|
|
|
|
|
+ var codeLines = [];
|
|
|
|
|
+
|
|
|
|
|
+ function closeList() {
|
|
|
|
|
+ if (inList) { out.push(inList === 'ul' ? '</ul>' : '</ol>'); inList = false; }
|
|
|
|
|
+ }
|
|
|
|
|
+ function flushParagraph() {
|
|
|
|
|
+ if (!paragraph.length) return;
|
|
|
|
|
+ closeList();
|
|
|
|
|
+ out.push('<p>' + paragraph.map(mdInline).join('<br>') + '</p>');
|
|
|
|
|
+ paragraph = [];
|
|
|
|
|
+ }
|
|
|
|
|
+ function flushQuote() {
|
|
|
|
|
+ if (!quote.length) return;
|
|
|
|
|
+ closeList();
|
|
|
|
|
+ out.push('<blockquote>' + quote.map(mdInline).join('<br>') + '</blockquote>');
|
|
|
|
|
+ quote = [];
|
|
|
|
|
+ }
|
|
|
|
|
+ function flushBlocks() { flushParagraph(); flushQuote(); }
|
|
|
|
|
|
|
|
for (var i = 0; i < lines.length; i++) {
|
|
for (var i = 0; i < lines.length; i++) {
|
|
|
var line = lines[i];
|
|
var line = lines[i];
|
|
|
|
|
|
|
|
|
|
+ if (inCode) {
|
|
|
|
|
+ if (/^```\s*$/.test(line)) {
|
|
|
|
|
+ out.push('<pre><code' + (codeLang ? ' class="language-' + codeLang + '"' : '') + '>' +
|
|
|
|
|
+ codeLines.join('\n') + '</code></pre>');
|
|
|
|
|
+ inCode = false;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ codeLines.push(line);
|
|
|
|
|
+ }
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
if (/^```/.test(line)) {
|
|
if (/^```/.test(line)) {
|
|
|
- if (inCode) { out.push('</code></pre>'); inCode = false; }
|
|
|
|
|
- else { closeList(); out.push('<pre><code>'); inCode = true; }
|
|
|
|
|
|
|
+ flushBlocks();
|
|
|
|
|
+ closeList();
|
|
|
|
|
+ codeLang = line.slice(3).trim();
|
|
|
|
|
+ codeLines = [];
|
|
|
|
|
+ inCode = true;
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
- if (inCode) { out.push(line); continue; }
|
|
|
|
|
|
|
|
|
|
var h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
var h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
|
- if (h) { closeList(); out.push('<h' + h[1].length + '>' + mdInline(h[2]) + '</h' + h[1].length + '>'); continue; }
|
|
|
|
|
|
|
+ if (h) { flushBlocks(); closeList(); out.push('<h' + h[1].length + '>' + mdInline(h[2]) + '</h' + h[1].length + '>'); continue; }
|
|
|
|
|
|
|
|
- if (/^---+\s*$/.test(line)) { closeList(); out.push('<hr>'); continue; }
|
|
|
|
|
|
|
+ if (/^---+\s*$/.test(line)) { flushBlocks(); closeList(); out.push('<hr>'); continue; }
|
|
|
|
|
|
|
|
var bq = line.match(/^>\s*(.*)$/);
|
|
var bq = line.match(/^>\s*(.*)$/);
|
|
|
- if (bq) { closeList(); out.push('<blockquote>' + mdInline(bq[1]) + '</blockquote>'); continue; }
|
|
|
|
|
|
|
+ if (bq) { flushParagraph(); quote.push(bq[1]); continue; }
|
|
|
|
|
|
|
|
var ul = line.match(/^[-*]\s+(.*)$/);
|
|
var ul = line.match(/^[-*]\s+(.*)$/);
|
|
|
- if (ul) { if (inList !== 'ul') { closeList(); out.push('<ul>'); inList = 'ul'; } out.push('<li>' + mdInline(ul[1]) + '</li>'); continue; }
|
|
|
|
|
|
|
+ if (ul) {
|
|
|
|
|
+ flushBlocks();
|
|
|
|
|
+ if (inList !== 'ul') { closeList(); out.push('<ul>'); inList = 'ul'; }
|
|
|
|
|
+ out.push('<li>' + mdInline(ul[1]) + '</li>');
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
var ol = line.match(/^\d+\.\s+(.*)$/);
|
|
var ol = line.match(/^\d+\.\s+(.*)$/);
|
|
|
- if (ol) { if (inList !== 'ol') { closeList(); out.push('<ol>'); inList = 'ol'; } out.push('<li>' + mdInline(ol[1]) + '</li>'); continue; }
|
|
|
|
|
|
|
+ if (ol) {
|
|
|
|
|
+ flushBlocks();
|
|
|
|
|
+ if (inList !== 'ol') { closeList(); out.push('<ol>'); inList = 'ol'; }
|
|
|
|
|
+ out.push('<li>' + mdInline(ol[1]) + '</li>');
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- closeList();
|
|
|
|
|
- if (line.trim() === '') continue;
|
|
|
|
|
- out.push('<p>' + mdInline(line) + '</p>');
|
|
|
|
|
|
|
+ if (line.trim() === '') { flushBlocks(); closeList(); continue; }
|
|
|
|
|
+ flushQuote();
|
|
|
|
|
+ paragraph.push(line);
|
|
|
}
|
|
}
|
|
|
|
|
+ flushBlocks();
|
|
|
closeList();
|
|
closeList();
|
|
|
- if (inCode) out.push('</code></pre>');
|
|
|
|
|
|
|
+ if (inCode) {
|
|
|
|
|
+ out.push('<pre><code' + (codeLang ? ' class="language-' + codeLang + '"' : '') + '>' +
|
|
|
|
|
+ codeLines.join('\n') + '</code></pre>');
|
|
|
|
|
+ }
|
|
|
return out.join('\n');
|
|
return out.join('\n');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- function mdInline(text) {
|
|
|
|
|
- return text
|
|
|
|
|
- .replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
|
|
|
- .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
|
|
|
- .replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
|
|
|
|
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
/**
|
|
/**
|
|
|
* Demote child header tags by N levels (h1 → h2, etc.), capping at h6.
|
|
* Demote child header tags by N levels (h1 → h2, etc.), capping at h6.
|
|
|
*/
|
|
*/
|