/**
* 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  - must come before link handler
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, url) {
if (/^https?:/i.test(url)) {
return '';
}
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
') + '';
});
// Wrap consecutive unordered/task items in
html = html.replace(/((?:%%(?:ULI|TDI|TTI)%%).+(?:\n(?:%%(?:ULI|TDI|TTI)%%).+)*)/g, function(block) {
return '
' + block
.replace(/%%ULI%%(.+)/g, '
$1
')
.replace(/%%TDI%%(.+)/g, '
☑ $1
')
.replace(/%%TTI%%(.+)/g, '
☐ $1
')
+ '
';
});
// Blockquotes (> text)
html = html.replace(/^>\s+(.+)$/gm, '
$1
');
// Horizontal rules (--- or ***)
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '');
// Line breaks (two spaces at end of line or double newline)
html = html.replace(/ \n/g, ' ');
html = html.replace(/\n\n/g, '
');
// Restore code blocks and inline code
codeBlocks.forEach((block, i) => {
html = html.replace('%%CODEBLOCK' + i + '%%', block);
});
inlineCodes.forEach((code, i) => {
html = html.replace('%%INLINECODE' + i + '%%', code);
});
// Restore footnote reference placeholders
fnRefs.forEach(function(ref, i) {
html = html.replace('%%FNREF' + i + '%%',
'[' + ref.n + ']');
});
// Wrap in paragraph if not already wrapped
if (!html.startsWith('<')) {
html = '
' + html + '
';
}
// Append footnote definitions block
if (footnoteOrder.length) {
html += '';
footnoteOrder.forEach(function(label, i) {
html += '