/**
* Simple Markdown Parser for Tinker Tickets
* Supports basic markdown formatting without external dependencies
*/
function parseMarkdown(markdown) {
if (!markdown) return '';
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);
// 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');
// 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, ftp)
html = html.replace(/(?])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '$1');
// Headers (# H1, ## H2, etc.)
html = html.replace(/^### (.+)$/gm, '
$1
');
html = html.replace(/^## (.+)$/gm, '
$1
');
html = html.replace(/^# (.+)$/gm, '
$1
');
// Lists
// Unordered lists (- item or * item)
html = html.replace(/^\s*[-*]\s+(.+)$/gm, '
$1
');
html = html.replace(/(
.*<\/li>)/s, '
$1
');
// Ordered lists (1. item)
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '
$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);
});
// Wrap in paragraph if not already wrapped
if (!html.startsWith('<')) {
html = '
' + html + '
';
}
return html;
}
/**
* Parse markdown tables
* Supports: | Header | Header |
* |--------|--------|
* | Cell | Cell |
*/
function parseMarkdownTables(html) {
const lines = html.split('\n');
const result = [];
let inTable = false;
let tableRows = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Check if line is a table row (starts and ends with |, or has | in the middle)
if (line.match(/^\|.*\|$/) || line.match(/^[^|]+\|[^|]+/)) {
// Check if next line is separator (|---|---|)
const nextLine = lines[i + 1] ? lines[i + 1].trim() : '';
const isSeparator = line.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/);
if (!inTable && !isSeparator) {
// Start of table - check if this is a header row
if (nextLine.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/)) {
inTable = true;
tableRows.push({ type: 'header', content: line });
continue;
}
}
if (inTable) {
if (isSeparator) {
// Skip separator line
continue;
}
tableRows.push({ type: 'body', content: line });
continue;
}
}
// Not a table row - flush any accumulated table
if (inTable && tableRows.length > 0) {
result.push(buildTable(tableRows));
tableRows = [];
inTable = false;
}
result.push(lines[i]);
}
// Flush remaining table
if (tableRows.length > 0) {
result.push(buildTable(tableRows));
}
return result.join('\n');
}
/**
* Build HTML table from parsed rows
*/
function buildTable(rows) {
if (rows.length === 0) return '';
let html = '
';
rows.forEach((row, index) => {
const cells = row.content.split('|').filter(cell => cell.trim() !== '');
const tag = row.type === 'header' ? 'th' : 'td';
const wrapper = row.type === 'header' ? 'thead' : (index === 1 ? 'tbody' : '');
if (wrapper === 'thead') html += '';
if (wrapper === 'tbody') html += '';
html += '
';
cells.forEach(cell => {
html += `<${tag}>${cell.trim()}${tag}>`;
});
html += '
';
if (row.type === 'header') html += '';
});
html += '
';
return html;
}
// Apply markdown rendering to all elements with data-markdown attribute
function renderMarkdownElements() {
document.querySelectorAll('[data-markdown]').forEach(element => {
const markdownText = element.getAttribute('data-markdown') || element.textContent;
element.innerHTML = parseMarkdown(markdownText);
});
}
// Apply markdown to description and comments on page load
document.addEventListener('DOMContentLoaded', renderMarkdownElements);
// Expose for manual use
window.parseMarkdown = parseMarkdown;
window.renderMarkdownElements = renderMarkdownElements;
// ========================================
// Rich Text Editor Toolbar Functions
// ========================================
/**
* Insert markdown formatting around selection
*/
function insertMarkdownFormat(textareaId, prefix, suffix) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
// Insert formatting
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
textarea.value = newText;
// Set cursor position
if (selectedText) {
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
} else {
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
}
textarea.focus();
// Trigger input event to update preview if enabled
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Insert markdown at cursor position
*/
function insertMarkdownText(textareaId, text) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const value = textarea.value;
textarea.value = value.substring(0, start) + text + value.substring(start);
textarea.setSelectionRange(start + text.length, start + text.length);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Toolbar button handlers
*/
function toolbarBold(textareaId) {
insertMarkdownFormat(textareaId, '**', '**');
}
function toolbarItalic(textareaId) {
insertMarkdownFormat(textareaId, '_', '_');
}
function toolbarCode(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
// Use code block for multi-line, inline code for single line
if (selectedText.includes('\n')) {
insertMarkdownFormat(textareaId, '```\n', '\n```');
} else {
insertMarkdownFormat(textareaId, '`', '`');
}
}
function toolbarLink(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
if (selectedText) {
// Wrap selected text as link text
insertMarkdownFormat(textareaId, '[', '](url)');
} else {
insertMarkdownText(textareaId, '[link text](url)');
}
}
function toolbarList(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
// Find start of current line
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
lineStart--;
}
// Insert list marker at beginning of line
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
textarea.setSelectionRange(start + 2, start + 2);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
function toolbarHeading(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
// Find start of current line
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
lineStart--;
}
// Insert heading marker at beginning of line
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
textarea.setSelectionRange(start + 3, start + 3);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
function toolbarQuote(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
// Find start of current line
let lineStart = start;
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
lineStart--;
}
// Insert quote marker at beginning of line
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
textarea.setSelectionRange(start + 2, start + 2);
textarea.focus();
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Create and insert toolbar HTML for a textarea
*/
function createEditorToolbar(textareaId, containerId) {
const container = document.getElementById(containerId);
if (!container) return;
const toolbar = document.createElement('div');
toolbar.className = 'editor-toolbar';
toolbar.innerHTML = `
`;
// Add event delegation for toolbar buttons
toolbar.addEventListener('click', function(e) {
const btn = e.target.closest('[data-toolbar-action]');
if (!btn) return;
const action = btn.dataset.toolbarAction;
const targetId = btn.dataset.textarea;
switch (action) {
case 'bold': toolbarBold(targetId); break;
case 'italic': toolbarItalic(targetId); break;
case 'code': toolbarCode(targetId); break;
case 'heading': toolbarHeading(targetId); break;
case 'list': toolbarList(targetId); break;
case 'quote': toolbarQuote(targetId); break;
case 'link': toolbarLink(targetId); break;
}
});
container.insertBefore(toolbar, container.firstChild);
}
// Expose toolbar functions globally
window.toolbarBold = toolbarBold;
window.toolbarItalic = toolbarItalic;
window.toolbarCode = toolbarCode;
window.toolbarLink = toolbarLink;
window.toolbarList = toolbarList;
window.toolbarHeading = toolbarHeading;
window.toolbarQuote = toolbarQuote;
window.createEditorToolbar = createEditorToolbar;
window.insertMarkdownFormat = insertMarkdownFormat;
window.insertMarkdownText = insertMarkdownText;
// ========================================
// Auto-link URLs in plain text (non-markdown)
// ========================================
/**
* Convert plain text URLs to clickable links
* Used for non-markdown comments
*/
function autoLinkUrls(text) {
if (!text) return '';
// Match URLs that aren't already in an href attribute
return text.replace(/(?])(https?:\/\/[^\s<>\[\]()]+)/g,
'$1');
}
/**
* Process all non-markdown comment elements to auto-link URLs
*/
function processPlainTextComments() {
document.querySelectorAll('.comment-text:not([data-markdown])').forEach(element => {
// Only process if not already processed
if (element.dataset.linksProcessed) return;
element.innerHTML = autoLinkUrls(element.innerHTML);
element.dataset.linksProcessed = 'true';
});
}
// Run on page load
document.addEventListener('DOMContentLoaded', function() {
processPlainTextComments();
});
// Expose for manual use
window.autoLinkUrls = autoLinkUrls;
window.processPlainTextComments = processPlainTextComments;