diff --git a/static/panels.js b/static/panels.js index d1fa17e1..fdeeb510 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1274,17 +1274,188 @@ function _kanbanRenderSidebar(columns){ } +/** + * Render inline markdown (bold, italic, code, links, strikethrough). + * Input is already HTML-escaped. + */ function _kanbanRenderMarkdownInline(escaped){ return String(escaped || '') + .replace(/~~([^~\n]+)~~/g, (_m, text) => `${text}`) .replace(/`([^`\n]+)`/g, (_m, code) => `${code}`) .replace(/\*\*([^*\n]+)\*\*/g, (_m, text) => `${text}`) - .replace(/(^|[^*])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}${text}`) + .replace(/(^|[^*a-zA-Z0-9])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}${text}`) .replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g, (_m, text, href) => `${text}`); } +/** + * Render full markdown block content: headings, code blocks, lists, tables, + * task lists, blockquotes, horizontal rules, paragraphs + inline formatting. + */ function _kanbanRenderMarkdown(source){ if (!source) return ''; - return `
${esc(source).split(/\r?\n/).map(line => line.trim() ? `

${_kanbanRenderMarkdownInline(line)}

` : '').join('')}
`; + const lines = esc(source).split(/\r?\n/); + const out = []; + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + + // ── Code block ── + if (/^```/.test(trimmed)) { + const lang = trimmed.slice(3).trim(); + const codeLines = []; + i++; + while (i < lines.length && !/^```/.test(lines[i].trim())) { + codeLines.push(lines[i]); + i++; + } + i++; // skip closing ``` + const codeHtml = codeLines.join('\n'); + out.push(lang + ? `
${codeHtml}
` + : `
${codeHtml}
`); + continue; + } + + // ── Horizontal rule ── + if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) { + out.push('
'); + i++; + continue; + } + + // ── Heading ── + const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + const level = headingMatch[1].length; + out.push(`${_kanbanRenderMarkdownInline(headingMatch[2])}`); + i++; + continue; + } + + // ── Blockquote ── + if (/^>\s?/.test(trimmed)) { + const quoteLines = []; + while (i < lines.length && /^>\s?/.test(lines[i].trim())) { + quoteLines.push(lines[i].trim().replace(/^>\s?/, '')); + i++; + } + out.push(`
${_kanbanRenderMarkdownInline(quoteLines.join('
'))}
`); + continue; + } + + // ── Table row ── + if (/^\|.+\|$/.test(trimmed)) { + const tableRows = []; + const tableAligns = []; + while (i < lines.length && /^\|.+\|$/.test(lines[i].trim())) { + const row = lines[i].trim(); + // Detect alignment separator row + if (/^\|[\s:]*-{3,}[\s:]*\|/.test(row)) { + const cells = row.split('|').filter(c => c.trim().length > 0); + cells.forEach(c => { + const t = c.trim(); + if (t.startsWith(':') && t.endsWith(':')) tableAligns.push('center'); + else if (t.endsWith(':')) tableAligns.push('right'); + else tableAligns.push('left'); + }); + } else { + const cells = row.split('|').filter(c => c.trim().length > 0); + tableRows.push(cells.map((c, ci) => { + const align = tableAligns[ci] ? ` style="text-align:${tableAligns[ci]}"` : ''; + return `${_kanbanRenderMarkdownInline(c.trim())}`; + }).join('')); + } + i++; + } + if (tableRows.length) { + out.push(`${tableRows.map(r => `${r}`).join('')}
`); + } + continue; + } + + // ── Task list item ── + const taskMatch = trimmed.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/); + if (taskMatch) { + const checked = taskMatch[1] !== ' '; + const text = taskMatch[2]; + const items = []; + items.push(`
  • ${_kanbanRenderMarkdownInline(text)}
  • `); + i++; + // Collect continuation items + while (i < lines.length) { + const next = lines[i].trim(); + const nextTask = next.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/); + const nextLi = next.match(/^[-*+]\s+(.+)$/); + if (nextTask) { + const c = nextTask[1] !== ' '; + items.push(`
  • ${_kanbanRenderMarkdownInline(nextTask[2])}
  • `); + i++; + } else if (nextLi) { + items.push(`
  • ${_kanbanRenderMarkdownInline(nextLi[1])}
  • `); + i++; + } else { + break; + } + } + out.push(``); + continue; + } + + // ── Unordered list item ── + const ulMatch = trimmed.match(/^[-*+]\s+(.+)$/); + if (ulMatch) { + const items = []; + items.push(`
  • ${_kanbanRenderMarkdownInline(ulMatch[1])}
  • `); + i++; + while (i < lines.length) { + const next = lines[i].trim(); + const nextUl = next.match(/^[-*+]\s+(.+)$/); + const nextTask = next.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/); + if (nextTask) break; // let task list handler get it + if (nextUl) { + items.push(`
  • ${_kanbanRenderMarkdownInline(nextUl[1])}
  • `); + i++; + } else { + break; + } + } + out.push(``); + continue; + } + + // ── Ordered list item ── + const olMatch = trimmed.match(/^\d+\.\s+(.+)$/); + if (olMatch) { + const items = []; + items.push(`
  • ${_kanbanRenderMarkdownInline(olMatch[1])}
  • `); + i++; + while (i < lines.length) { + const next = lines[i].trim(); + const nextOl = next.match(/^\d+\.\s+(.+)$/); + if (nextOl) { + items.push(`
  • ${_kanbanRenderMarkdownInline(nextOl[1])}
  • `); + i++; + } else { + break; + } + } + out.push(`
      ${items.join('')}
    `); + continue; + } + + // ── Empty line ── + if (!trimmed) { + out.push(''); + i++; + continue; + } + + // ── Paragraph ── + out.push(`

    ${_kanbanRenderMarkdownInline(trimmed)}

    `); + i++; + } + return `
    ${out.join('\n')}
    `; } function _kanbanFormatDuration(seconds){ @@ -1851,7 +2022,7 @@ function _kanbanCommentHtml(comment){ const by = comment.author || comment.created_by || comment.actor || ''; const at = _kanbanFormatTimestamp(comment.created_at || comment.ts || ''); return `
    -
    ${esc(body)}
    +
    ${_kanbanRenderMarkdown(body)}
    ${esc([by, at].filter(Boolean).join(' · '))}
    `; } @@ -2403,7 +2574,7 @@ function _kanbanRenderTaskDetail(data){
    ${esc(title)}
    -
    ${esc(body)}
    +
    ${_kanbanRenderMarkdown(body)}
    ${meta.length ? `
    ${esc(meta.join(' · '))}
    ` : ''}
    ${statusButtons}
    diff --git a/static/style.css b/static/style.css index 80f1e96e..5f7bb3e0 100644 --- a/static/style.css +++ b/static/style.css @@ -4221,7 +4221,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .kanban-run-dispatch-btn:hover{ background:color-mix(in srgb,var(--accent,#FFD700) 24%,transparent); } -.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;white-space:pre-wrap;margin-bottom:6px;} +.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;margin-bottom:6px;} .kanban-status-actions{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 4px;} .kanban-status-actions .btn{font-size:11px;padding:4px 8px;} /* Generic styled buttons used throughout the Kanban panel. The Kanban PR @@ -4291,7 +4291,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .kanban-detail-section h3{font-size:12px;font-weight:650;color:var(--text);margin:0 0 8px;} .kanban-detail-row{padding:8px 0;border-top:1px solid var(--border);} .kanban-detail-row:first-of-type{border-top:0;padding-top:0;} -.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;white-space:pre-wrap;} +.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;}.kanban-detail-row-main .hermes-kanban-md p:last-child{margin-bottom:0;} .kanban-detail-row-meta{font-size:10px;color:var(--muted);margin-top:4px;} .kanban-detail-pre{font-size:11px;line-height:1.4;white-space:pre-wrap;word-break:break-word;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:6px;margin:6px 0 0;color:var(--muted);} .kanban-detail-empty{font-size:12px;color:var(--muted);} @@ -4323,7 +4323,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .kanban-card-stale-amber{border-color:rgba(245,197,66,.55)} .kanban-card-stale-red{border-color:rgba(255,95,95,.65)} .kanban-column.drop-target{outline:2px solid var(--accent);outline-offset:-2px} -.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em} +.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em}.hermes-kanban-md h1,.hermes-kanban-md h2,.hermes-kanban-md h3,.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{margin:10px 0 4px;font-weight:650;color:var(--text)}.hermes-kanban-md h1{font-size:15px}.hermes-kanban-md h2{font-size:14px}.hermes-kanban-md h3{font-size:13px}.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{font-size:12px}.hermes-kanban-md ul,.hermes-kanban-md ol{margin:4px 0;padding-left:20px}.hermes-kanban-md li{margin:2px 0}.hermes-kanban-md li.checked{opacity:.6}.hermes-kanban-md li input[type=checkbox]{margin:0 4px 0 0;vertical-align:middle}.hermes-kanban-md table{border-collapse:collapse;margin:6px 0;font-size:11px;width:100%}.hermes-kanban-md td{border:1px solid var(--border);padding:4px 6px;vertical-align:top}.hermes-kanban-md blockquote{margin:4px 0;padding:2px 8px;border-left:3px solid var(--accent);color:var(--muted);font-size:12px}.hermes-kanban-md pre{background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:8px;margin:6px 0;overflow-x:auto;font-size:11px;line-height:1.4;color:var(--text)}.hermes-kanban-md hr{border:none;border-top:1px solid var(--border);margin:8px 0} @media (max-width: 640px){ .kanban-board{scroll-snap-type:x mandatory;}