/** * Simple Markdown Parser for Tinker Tickets * Supports basic markdown formatting without external dependencies */ function parseMarkdown(markdown) { if (!markdown) return ''; // Footnotes — collect definitions and mark references with placeholders // (must happen before HTML escaping so tags don't get escaped) const footnotes = {}; const footnoteOrder = []; const fnRefs = []; markdown = markdown.replace(/^\[\^([^\]]+)\]:\s+(.+)$/gm, function(_, label, text) { footnotes[label] = text; return ''; }); markdown = markdown.replace(/\[\^([^\]]+)\]/g, function(_, label) { if (!footnotes[label]) return '[^' + label + ']'; if (!footnoteOrder.includes(label)) footnoteOrder.push(label); const n = footnoteOrder.indexOf(label) + 1; fnRefs.push({ label, n }); return '%%FNREF' + (fnRefs.length - 1) + '%%'; }); let html = markdown; // Escape HTML first to prevent XSS html = html.replace(/&/g, '&') .replace(//g, '>'); // Ticket references (#123456789) - convert to clickable links html = html.replace(/#(\d{9})\b/g, '#$1'); // Code blocks (```code```) - preserve content and don't process further const codeBlocks = []; html = html.replace(/```([\s\S]*?)```/g, function(match, code) { codeBlocks.push('
' + code + '
'); return '%%CODEBLOCK' + (codeBlocks.length - 1) + '%%'; }); // Inline code (`code`) - preserve and don't process further const inlineCodes = []; html = html.replace(/`([^`]+)`/g, function(match, code) { inlineCodes.push('' + code + ''); return '%%INLINECODE' + (inlineCodes.length - 1) + '%%'; }); // Tables (must be processed before other block elements) html = parseMarkdownTables(html); // Emoji :name: — common set html = replaceEmoji(html); // Bold (**text** or __text__) html = html.replace(/\*\*(.+?)\*\*/g, '$1'); html = html.replace(/__(.+?)__/g, '$1'); // Italic (*text* or _text_) html = html.replace(/\*(.+?)\*/g, '$1'); html = html.replace(/_(.+?)_/g, '$1'); // Strikethrough (~~text~~) — must run before subscript (~) html = html.replace(/~~(.+?)~~/g, '$1'); // Highlight (==text==) html = html.replace(/==(.+?)==/g, '$1'); // Subscript H~2~O — single tilde (not preceded/followed by another tilde) html = html.replace(/(?$1'); // Superscript X^2^ — caret pair html = html.replace(/\^([^\^\n]+?)\^/g, '$1'); // Images ![alt](url) - must come before link handler html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, url) { if (/^https?:/i.test(url)) { return '' + alt + ''; } return match; }); // Links [text](url) - only allow safe protocols html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) { // Only allow http, https, mailto protocols if (/^(https?:|mailto:|\/)/i.test(url)) { return '' + text + ''; } // Block potentially dangerous protocols (javascript:, data:, etc.) return text; }); // Auto-link bare URLs (http, https) html = html.replace(/(?])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '$1'); // Headings with optional {#id} anchor — ### My Heading {#my-id} html = html.replace(/^(#{1,6})\s+(.+?)\s*(?:\{#([a-z0-9_-]+)\})?$/gm, function(match, hashes, text, id) { const level = hashes.length; const idAttr = id ? ' id="' + id + '"' : ''; return '' + text + ''; }); // Lists — tag each item type with a placeholder, then wrap consecutive runs html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '%%OLI%%$1'); html = html.replace(/^\s*[-*+]\s+\[x\]\s+(.+)$/gim, '%%TDI%%$1'); html = html.replace(/^\s*[-*+]\s+\[ \]\s+(.+)$/gm, '%%TTI%%$1'); html = html.replace(/^\s*[-*+]\s+(.+)$/gm, '%%ULI%%$1'); // Wrap consecutive ordered items in
    html = html.replace(/(%%OLI%%.+(\n%%OLI%%.+)*)/g, function(block) { return '
      ' + block.replace(/%%OLI%%(.+)/g, '
    1. $1
    2. ') + '
    '; }); // Wrap consecutive unordered/task items in