kanban: full markdown rendering for task description and comments

- Rewrote _kanbanRenderMarkdown() from basic paragraph wrapper to a
  line-by-line block processor supporting headings, code blocks, lists,
  task lists, tables, blockquotes, horizontal rules, and strikethrough.
- Added CSS for all new elements (table borders, code blocks, checkboxes,
  blockquote accent, heading sizing, etc.).
- Dropped white-space: pre-wrap from .kanban-task-preview-body and
  .kanban-detail-row-main since markdown now handles layout.
- Applied _kanbanRenderMarkdown() to task description (was esc()) and
  comment body (was esc()) in the task detail view.
This commit is contained in:
humayunak
2026-05-20 10:19:37 +05:00
committed by nesquena-hermes
parent 073bd3e1e2
commit 7983e025c4
2 changed files with 178 additions and 7 deletions
+175 -4
View File
@@ -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) => `<del>${text}</del>`)
.replace(/`([^`\n]+)`/g, (_m, code) => `<code>${code}</code>`)
.replace(/\*\*([^*\n]+)\*\*/g, (_m, text) => `<strong>${text}</strong>`)
.replace(/(^|[^*])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}<em>${text}</em>`)
.replace(/(^|[^*a-zA-Z0-9])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}<em>${text}</em>`)
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g, (_m, text, href) => `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`);
}
/**
* 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 `<div class="hermes-kanban-md">${esc(source).split(/\r?\n/).map(line => line.trim() ? `<p>${_kanbanRenderMarkdownInline(line)}</p>` : '').join('')}</div>`;
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
? `<pre class="hermes-kanban-code"><code class="language-${_kanbanRenderMarkdownInline(lang)}">${codeHtml}</code></pre>`
: `<pre class="hermes-kanban-code"><code>${codeHtml}</code></pre>`);
continue;
}
// ── Horizontal rule ──
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) {
out.push('<hr>');
i++;
continue;
}
// ── Heading ──
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
out.push(`<h${level}>${_kanbanRenderMarkdownInline(headingMatch[2])}</h${level}>`);
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(`<blockquote>${_kanbanRenderMarkdownInline(quoteLines.join('<br>'))}</blockquote>`);
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 `<td${align}>${_kanbanRenderMarkdownInline(c.trim())}</td>`;
}).join(''));
}
i++;
}
if (tableRows.length) {
out.push(`<table><tbody>${tableRows.map(r => `<tr>${r}</tr>`).join('')}</tbody></table>`);
}
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(`<li class="hermes-kanban-task${checked ? ' checked' : ''}"><input type="checkbox"${checked ? ' checked' : ''} disabled> ${_kanbanRenderMarkdownInline(text)}</li>`);
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(`<li class="hermes-kanban-task${c ? ' checked' : ''}"><input type="checkbox"${c ? ' checked' : ''} disabled> ${_kanbanRenderMarkdownInline(nextTask[2])}</li>`);
i++;
} else if (nextLi) {
items.push(`<li>${_kanbanRenderMarkdownInline(nextLi[1])}</li>`);
i++;
} else {
break;
}
}
out.push(`<ul>${items.join('')}</ul>`);
continue;
}
// ── Unordered list item ──
const ulMatch = trimmed.match(/^[-*+]\s+(.+)$/);
if (ulMatch) {
const items = [];
items.push(`<li>${_kanbanRenderMarkdownInline(ulMatch[1])}</li>`);
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(`<li>${_kanbanRenderMarkdownInline(nextUl[1])}</li>`);
i++;
} else {
break;
}
}
out.push(`<ul>${items.join('')}</ul>`);
continue;
}
// ── Ordered list item ──
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/);
if (olMatch) {
const items = [];
items.push(`<li>${_kanbanRenderMarkdownInline(olMatch[1])}</li>`);
i++;
while (i < lines.length) {
const next = lines[i].trim();
const nextOl = next.match(/^\d+\.\s+(.+)$/);
if (nextOl) {
items.push(`<li>${_kanbanRenderMarkdownInline(nextOl[1])}</li>`);
i++;
} else {
break;
}
}
out.push(`<ol>${items.join('')}</ol>`);
continue;
}
// ── Empty line ──
if (!trimmed) {
out.push('');
i++;
continue;
}
// ── Paragraph ──
out.push(`<p>${_kanbanRenderMarkdownInline(trimmed)}</p>`);
i++;
}
return `<div class="hermes-kanban-md">${out.join('\n')}</div>`;
}
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 `<div class="kanban-detail-row">
<div class="kanban-detail-row-main">${esc(body)}</div>
<div class="kanban-detail-row-main">${_kanbanRenderMarkdown(body)}</div>
<div class="kanban-detail-row-meta">${esc([by, at].filter(Boolean).join(' · '))}</div>
</div>`;
}
@@ -2403,7 +2574,7 @@ function _kanbanRenderTaskDetail(data){
<div class="kanban-task-preview-title">${esc(title)}</div>
<button class="btn secondary kanban-edit-btn" onclick="openKanbanEdit('${esc(task.id)}')" data-i18n="kanban_edit_task" title="${esc(t('kanban_edit_task') || 'Edit task')}">${esc(t('kanban_edit_task') || 'Edit task')}</button>
</div>
<div class="kanban-task-preview-body">${esc(body)}</div>
<div class="kanban-task-preview-body">${_kanbanRenderMarkdown(body)}</div>
${meta.length ? `<div class="kanban-meta">${esc(meta.join(' · '))}</div>` : ''}
<div class="kanban-status-actions">${statusButtons}</div>
<div class="kanban-detail-grid">
+3 -3
View File
@@ -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;}