/* CanvasRefined - lightweight Markdown renderer for dashboard notes. Renders a small, notes-friendly subset of Markdown to HTML: headings, bold, italic, strikethrough, inline code, fenced code blocks, links, images, autolinks, blockquotes, ordered/unordered lists, task lists, horizontal rules, and paragraphs. All user-supplied text is HTML-escaped before any formatting is applied, and links/images are URL-sanitized, so the returned HTML is safe to assign via innerHTML. Exposes window.renderMarkdown(text) -> html. */ (function () { "use strict"; function escapeHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function sanitizeUrl(url) { const u = String(url == null ? "" : url).trim(); if (u === "") return ""; if (/^(https?:|mailto:|ftp:|tel:)/i.test(u)) return u; if (/^(javascript:|vbscript:|file:|data:)/i.test(u)) return "#"; if (/^[#/?]/.test(u)) return u; // Block any other explicit protocol we did not whitelist. if (/^[a-z][a-z0-9+.\-]*:/i.test(u)) return "#"; return u; } function renderMarkdown(src) { if (src == null) return ""; src = String(src).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); // Stash protected HTML (code blocks/spans, autolinks) behind NUL-delimited // tokens so they survive escaping and inline formatting untouched. const stash = []; const keep = (html) => { stash.push(html); return "\u0000" + (stash.length - 1) + "\u0000"; }; // 1. Fenced code blocks: ```lang\n code ``` src = src.replace(/```([\w-]*)\n?([\s\S]*?)```/g, (m, lang, code) => { code = code.replace(/^\n/, "").replace(/\n$/, ""); const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : ""; return keep(`
${escapeHtml(code)}
`); }); // 2. Inline code: `code` src = src.replace(/`([^`\n]+)`/g, (m, code) => keep(`${escapeHtml(code)}`)); // 3. Autolinks: src = src.replace(/<(https?:\/\/[^\s<>]+)>/g, (m, url) => { const safe = escapeHtml(sanitizeUrl(url)); return keep(`${escapeHtml(url)}`); }); // 4. Inline formatting, applied to escaped text so nothing the user // typed can become live HTML. The URL capture allows one level of // balanced parentheses, e.g. https://en.wikipedia.org/wiki/Foo_(bar). const inline = (text) => { let t = escapeHtml(text); // images: ![alt](url) or ![alt](url "title") t = t.replace(/!\[([^\]]*)\]\(((?:[^()\s]|\([^)\s]*\))*)(?:\s+"([^"]*)")?\)/g, (m, alt, url, title) => { const tAttr = title ? ` title="${title}"` : ""; return `${alt}`; }); // links: [text](url) or [text](url "title") t = t.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^)\s]*\))*)(?:\s+"([^"]*)")?\)/g, (m, txt, url, title) => { const tAttr = title ? ` title="${title}"` : ""; return `${txt}`; }); // bold: **text** or __text__ t = t.replace(/\*\*([^*]+?)\*\*/g, "$1"); t = t.replace(/__([^_]+?)__/g, "$1"); // strikethrough: ~~text~~ t = t.replace(/~~([^~]+?)~~/g, "$1"); // italic: *text* (avoid *** by requiring non-star borders) t = t.replace(/(^|[^*])\*([^*]+?)\*(?!\*)/g, "$1$2"); // italic: _text_ (skip word-internal underscores like file_name) t = t.replace(/(^|[^_\w])_([^_]+?)_(?!\w)/g, "$1$2"); return t; }; const lines = src.split("\n"); const out = []; const n = lines.length; let i = 0; let taskSeq = 0; // ordinal of rendered task items, stable across code-block stashing const isToken = (s) => /^\u0000\d+\u0000$/.test(s); while (i < n) { const line = lines[i]; // Blank line separates blocks. if (/^\s*$/.test(line)) { i++; continue; } // A stashed block (e.g. a fenced code block) sitting on its own line. if (isToken(line)) { out.push(line); i++; continue; } // ATX heading: # Title (optional closing hashes) const h = line.match(/^(#{1,6})\s+(.*?)(?:\s+#{1,6})?$/); if (h) { const lvl = h[1].length; out.push("" + inline(h[2]) + ""); i++; continue; } // Horizontal rule: --- / *** / ___ if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { out.push("
"); i++; continue; } // Blockquote: > ... if (/^>{1}\s?/.test(line)) { const quote = []; while (i < n && /^>{1}\s?/.test(lines[i])) { quote.push(inline(lines[i].replace(/^>{1}\s?/, ""))); i++; } out.push("
" + quote.join("
") + "
"); continue; } // Unordered list: - / * / + (supports task lists: - [ ] / - [x]) if (/^\s*[-*+]\s+/.test(line)) { const items = []; while (i < n) { const m = lines[i].match(/^\s*[-*+]\s+(.*)$/); if (m) { const task = m[1].match(/^\[([ xX])\]\s+(.*)$/); if (task) { const checked = /x/i.test(task[1]); items.push( '
  • " + inline(task[2]) ); } else { items.push("
  • " + inline(m[1])); } i++; continue; } // Lazy continuation (indented) of the previous item. if (/^\s+\S/.test(lines[i]) && items.length) { items[items.length - 1] += "
    " + inline(lines[i].trim()); i++; continue; } break; } out.push(""); continue; } // Ordered list: 1. if (/^\s*\d+\.\s+/.test(line)) { const items = []; while (i < n) { const m = lines[i].match(/^\s*\d+\.\s+(.*)$/); if (m) { items.push("
  • " + inline(m[1])); i++; continue; } if (/^\s+\S/.test(lines[i]) && items.length) { items[items.length - 1] += "
    " + inline(lines[i].trim()); i++; continue; } break; } out.push("
      " + items.map((li) => li + "").join("") + "
    "); continue; } // Paragraph: gather consecutive lines until a block boundary. const para = [line]; i++; while (i < n) { const l = lines[i]; if (/^\s*$/.test(l)) break; if (isToken(l)) break; if (/^#{1,6}\s+/.test(l)) break; if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(l)) break; if (/^>{1}\s?/.test(l)) break; if (/^\s*[-*+]\s+/.test(l)) break; if (/^\s*\d+\.\s+/.test(l)) break; para.push(l); i++; } out.push("

    " + para.map(inline).join("
    ") + "

    "); } let html = out.join("\n"); // Restore stashed HTML. html = html.replace(/\u0000(\d+)\u0000/g, (m, idx) => stash[+idx] || ""); return html; } window.renderMarkdown = renderMarkdown; })();