Files
tinker_tickets/assets/js/markdown.js
T
jared cd83464c5d Add extended markdown: task lists, highlight, sub/superscript, heading IDs, emoji
- Task lists: - [x] / - [ ] with checkbox glyphs, done items struck through
- Highlight: ==text== -> <mark>
- Subscript: ~text~ -> <sub> (runs after ~~ strikethrough to avoid conflict)
- Superscript: ^text^ -> <sup>
- Heading IDs: ### Title {#my-id} adds id attribute for anchor links
- Ordered lists: now properly wrapped in <ol>
- Emoji: :name: shortcodes (~100 common emojis)
- CSS for all new elements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:13:38 -04:00

529 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Ticket references (#123456789) - convert to clickable links
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
// Code blocks (```code```) - preserve content and don't process further
const codeBlocks = [];
html = html.replace(/```([\s\S]*?)```/g, function(match, code) {
codeBlocks.push('<pre class="code-block"><code>' + code + '</code></pre>');
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 class="inline-code">' + code + '</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, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
// Italic (*text* or _text_)
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
// Strikethrough (~~text~~) — must run before subscript (~)
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
// Highlight (==text==)
html = html.replace(/==(.+?)==/g, '<mark>$1</mark>');
// Subscript H~2~O — single tilde (not preceded/followed by another tilde)
html = html.replace(/(?<!~)~([^~\n]+?)~(?!~)/g, '<sub>$1</sub>');
// Superscript X^2^ — caret pair
html = html.replace(/\^([^\^\n]+?)\^/g, '<sup>$1</sup>');
// Images ![alt](url) - must come before link handler
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, url) {
if (/^https?:/i.test(url)) {
return '<img src="' + url + '" alt="' + alt + '" class="md-image" loading="lazy">';
}
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 '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + text + '</a>';
}
// Block potentially dangerous protocols (javascript:, data:, etc.)
return text;
});
// Auto-link bare URLs (http, https)
html = html.replace(/(?<!["\'>])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
// 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 '<h' + level + idAttr + '>' + text + '</h' + level + '>';
});
// Task lists — must run before general list processing
html = html.replace(/^\s*-\s+\[x\]\s+(.+)$/gim, '<li class="task-item task-done"><span class="task-cb">&#x2611;</span> $1</li>');
html = html.replace(/^\s*-\s+\[ \]\s+(.+)$/gm, '<li class="task-item task-todo"><span class="task-cb">&#x2610;</span> $1</li>');
// Unordered lists (- item or * item) — wrap consecutive <li> in <ul>
html = html.replace(/^\s*[-*]\s+(.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>(?:(?!<li>|<\/ul>)[\s\S])*<\/li>(?:\n<li>(?:(?!<li>|<\/ul>)[\s\S])*<\/li>)*)/g, '<ul>$1</ul>');
// Ordered lists (1. item) — wrap in <ol>
html = html.replace(/(?:^\s*\d+\.\s+.+$\n?)+/gm, function(block) {
const items = block.trim().replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
return '<ol>' + items + '</ol>';
});
// Blockquotes (> text)
html = html.replace(/^&gt;\s+(.+)$/gm, '<blockquote>$1</blockquote>');
// Horizontal rules (--- or ***)
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>');
// Line breaks (two spaces at end of line or double newline)
html = html.replace(/ \n/g, '<br>');
html = html.replace(/\n\n/g, '</p><p>');
// 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 = '<p>' + html + '</p>';
}
return html;
}
/**
* Replace :emoji: shortcodes with Unicode characters
*/
const _emojiMap = {
// Faces & emotions
smile: '😊', grin: '😁', joy: '😂', rofl: '🤣', smiley: '😃', sweat_smile: '😅',
wink: '😉', blush: '😊', heart_eyes: '😍', kissing: '😗', thinking: '🤔',
raised_eyebrow: '🤨', neutral_face: '😐', expressionless: '😑', unamused: '😒',
roll_eyes: '🙄', pensive: '😔', confused: '😕', worried: '😟', cry: '😢',
sob: '😭', scream: '😱', angry: '😠', rage: '😡', skull: '💀', sunglasses: '😎',
nerd: '🤓', monocle: '🧐', clown: '🤡', ghost: '👻', robot: '🤖', alien: '👽',
// Hands & people
thumbsup: '👍', '+1': '👍', thumbsdown: '👎', '-1': '👎', clap: '👏',
wave: '👋', raised_hands: '🙌', pray: '🙏', point_up: '☝️', point_right: '👉',
point_left: '👈', point_down: '👇', fist: '✊', punch: '👊', v: '✌️', ok_hand: '👌',
muscle: '💪', eyes: '👀', eye: '👁️', ear: '👂', brain: '🧠', man: '👨', woman: '👩',
// Hearts & symbols
heart: '❤️', orange_heart: '🧡', yellow_heart: '💛', green_heart: '💚',
blue_heart: '💙', purple_heart: '💜', black_heart: '🖤', broken_heart: '💔',
star: '⭐', star2: '🌟', sparkles: '✨', fire: '🔥', boom: '💥', zap: '⚡',
check: '✅', white_check_mark: '✅', x: '❌', heavy_check_mark: '✔️',
warning: '⚠️', no_entry: '⛔', stop_sign: '🛑', prohibited: '🚫',
question: '❓', exclamation: '❗', grey_question: '❔', grey_exclamation: '❕',
100: '💯', tada: '🎉', confetti_ball: '🎊', trophy: '🏆', medal: '🥇',
// Tech & work
bug: '🐛', rocket: '🚀', computer: '💻', keyboard: '⌨️', mouse: '🖱️',
printer: '🖨️', phone: '📱', email: '📧', inbox_tray: '📥', outbox_tray: '📤',
memo: '📝', pencil: '✏️', pen: '🖊️', paperclip: '📎', link: '🔗',
hammer: '🔨', wrench: '🔧', gear: '⚙️', lock: '🔒', unlock: '🔓',
key: '🔑', mag: '🔍', bar_chart: '📊', chart_increasing: '📈', chart_decreasing: '📉',
clipboard: '📋', calendar: '📅', clock: '🕐', hourglass: '⏳', bell: '🔔',
mute: '🔇', loud_sound: '🔊', bulb: '💡', battery: '🔋', electric_plug: '🔌',
recycle: '♻️', package: '📦', label: '🏷️', bookmark: '🔖', flag: '🚩',
// Nature & misc
sun: '☀️', moon: '🌙', cloud: '☁️', snowflake: '❄️', umbrella: '☂️',
dog: '🐶', cat: '🐱', pizza: '🍕', coffee: '☕', beer: '🍺',
white_flag: '🏳️', checkered_flag: '🏁', construction: '🚧', sos: '🆘',
info: '️', new: '🆕', free: '🆓', cool: '🆒', up: '🆙', soon: '🔜',
};
function replaceEmoji(text) {
return text.replace(/:([a-z0-9_+\-]+):/g, function(match, name) {
return _emojiMap[name] || match;
});
}
/**
* 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 = '<table class="markdown-table">';
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 += '<thead>';
if (wrapper === 'tbody') html += '<tbody>';
html += '<tr>';
cells.forEach(cell => {
html += `<${tag}>${cell.trim()}</${tag}>`;
});
html += '</tr>';
if (row.type === 'header') html += '</thead>';
});
html += '</tbody></table>';
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 = `
<button type="button" data-toolbar-action="bold" data-textarea="${textareaId}" title="Bold (Ctrl+B)"><b>B</b></button>
<button type="button" data-toolbar-action="italic" data-textarea="${textareaId}" title="Italic (Ctrl+I)"><i>I</i></button>
<button type="button" data-toolbar-action="code" data-textarea="${textareaId}" title="Code">&lt;/&gt;</button>
<span class="toolbar-separator"></span>
<button type="button" data-toolbar-action="heading" data-textarea="${textareaId}" title="Heading">H</button>
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
<span class="toolbar-separator"></span>
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button>
`;
// 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,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="auto-link">$1</a>');
}
/**
* 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';
});
}
/**
* Render all [data-markdown] comment elements that haven't been rendered yet
*/
function renderMarkdownComments() {
document.querySelectorAll('.comment-text[data-markdown]:not([data-rendered])').forEach(el => {
el.innerHTML = parseMarkdown(el.textContent);
el.dataset.rendered = '1';
});
}
// Run on page load
document.addEventListener('DOMContentLoaded', function() {
renderMarkdownComments();
processPlainTextComments();
});
window.renderMarkdownComments = renderMarkdownComments;
// Expose for manual use
window.autoLinkUrls = autoLinkUrls;
window.processPlainTextComments = processPlainTextComments;