Răsfoiți Sursa

feat: stm-markdown directive — full markdown renderer with XSS safety, code blocks, lists, quotes, links

clanker 6 zile în urmă
părinte
comite
8a8fc50860
1 a modificat fișierele cu 154 adăugiri și 28 ștergeri
  1. 154 28
      js/statum.js

+ 154 - 28
js/statum.js

@@ -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) {
     var escaped = raw
+      .replace(/\r\n?/g, '\n')
       .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
       .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
     var lines = escaped.split('\n');
     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++) {
       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 (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;
       }
-      if (inCode) { out.push(line); continue; }
 
       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(/^&gt;\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+(.*)$/);
-      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+(.*)$/);
-      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();
-    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');
   }
 
-  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.
    */