pediatric-ai-scribe-v3/public/js/assistant/citations.js
Daniel 3d4a95fea4
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m50s
Revert the clinical assistant markdown changes and fix mermaid rendering only
Reverts 126d7928 and 2d292d12 in full. The markdown normaliser and the trailing
emphasis and dollar handling return to exactly the state tagged
pre-citation-fixes-20260828, which has been rendering acceptably in use.

The one change kept is mermaid. DOMPurify 3.1.6 strips an attribute whose value
contains "-->", and every mermaid flowchart contains one, so the sanitiser
removed data-mermaid and querySelectorAll('[data-mermaid]') never matched: the
diagram sat on "Rendering graph..." forever. Reproduced against the exact pinned
build from cdnjs; sequence diagrams using "->>" were unaffected, which is why
only flowcharts failed.

The diagram source is now percent-encoded into the attribute and decoded when
read, so no arrow ever appears in an attribute value. Nothing about the
sanitiser configuration changes and no markup is newly allowed. The reader
tolerates an unencoded value, so anything already in flight still renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:55:03 +02:00

250 lines
11 KiB
JavaScript

export function renderAssistantMarkdown(md, sources, options) {
var opts = options || {};
var codeBlocks = [];
var text = String(md || '').replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
var idx = codeBlocks.length;
codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code });
return '\n@@CODEBLOCK_' + idx + '@@\n';
});
text = stripOrphanMarkdownMarkers(normalizeMarkdownText(text));
text = renderLatexText(text, opts.katex);
text = normalizeAdjacentCitationClusters(text, sources || []);
var html;
if (opts.marked && typeof opts.marked.parse === 'function') {
html = opts.marked.parse(text, { breaks: true, gfm: true });
} else if (opts.markdownIt && typeof opts.markdownIt.render === 'function') {
html = opts.markdownIt.render(text);
} else {
html = fallbackMarkdown(text);
}
html = renderCitationLinks(html, sources || [], opts);
html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) {
var block = codeBlocks[Number(idx)] || { lang: '', code: '' };
// Percent-encoded: DOMPurify strips an attribute whose value contains "-->",
// which every mermaid flowchart has, leaving the diagram stuck on its
// placeholder. Encoding keeps the value intact; the reader decodes it.
if (block.lang === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(encodeURIComponent(block.code)) + '">Rendering graph...</div>';
if (block.lang === 'chart' || block.lang === 'chartjs') return '<canvas class="assistant-chart" data-chart="' + escapeAttr(block.code) + '"></canvas>';
return '<pre><code>' + escapeHtml(block.code) + '</code></pre>';
});
html = wrapTables(html);
return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html;
}
function wrapTables(html) {
return String(html || '')
.replace(/<table(\s[^>]*)?>/g, '<div class="assistant-table-scroll"><table$1>')
.replace(/<\/table>/g, '</table></div>');
}
export function renderCitationLinks(html, sources, options) {
var opts = options || {};
return String(html || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function (match, cluster) {
var nums = cluster.split(',').map(function (n) { return Number(n.trim()); }).filter(function (n) { return Number.isInteger(n) && n > 0; });
if (!nums.length || nums.some(function (n) { return !sources[n - 1]; })) return match;
return nums.map(function (n) {
var source = sources[n - 1];
var title = source ? source.title || source.resource || 'Source' : 'Source';
var page = source && (source.page || source.page_number || source.pageNumber);
var label = 'Source ' + n + ': ' + title + (page ? ', page ' + page : '');
var text = opts.citationLabel === 'number' ? String(n) : 'src';
return '<a class="assistant-cite" href="#assistant-source-' + n + '" data-source-number="' + n + '" title="' + escapeHtml(label) + '" aria-label="' + escapeAttr(label) + '">' + text + '</a>';
}).join(' ');
});
}
export function normalizeAdjacentCitationClusters(text, sources) {
var available = Array.isArray(sources) ? sources : [];
var normalized = String(text || '').replace(/((?:\[(?:\d+\s*,\s*)*\d+\]\s*)+)\[((?:\d+\s*,\s*)*\d+)(?=$|[.,;:])/g, function(match, completeClusters, trailingCluster) {
var nums = citationNumbers(completeClusters).concat(parseCitationCluster(trailingCluster));
if (!allCitationsAvailable(nums, available)) return match;
return formatCitationCluster(nums);
});
return normalized.replace(/(?:\[(?:\d+\s*,\s*)*\d+\]\s*){2,}/g, function(match) {
var nums = citationNumbers(match);
if (!allCitationsAvailable(nums, available)) return match;
return formatCitationCluster(nums);
});
}
function citationNumbers(text) {
var clusters = String(text || '').match(/\[((?:\d+\s*,\s*)*\d+)\]/g) || [];
var nums = [];
clusters.forEach(function(cluster) {
nums = nums.concat(parseCitationCluster(cluster.slice(1, -1)));
});
return nums;
}
function parseCitationCluster(cluster) {
return String(cluster || '').split(',').map(function(n) { return Number(n.trim()); }).filter(function(n) { return Number.isInteger(n) && n > 0; });
}
function allCitationsAvailable(nums, sources) {
return nums.length > 0 && nums.every(function(n) { return sources[n - 1]; });
}
function formatCitationCluster(nums) {
nums = Array.from(new Set(nums)).sort(function(a, b) { return a - b; });
return '[' + nums.join(', ') + ']';
}
export function normalizeMarkdownText(text) {
return stripOrphanMarkdownMarkers(normalizeTableSourceCitationCells(String(text || '')
.replace(/\r\n/g, '\n')
.replace(/(\[(?:\d+\s*,\s*)*\d+\])\s*[-–—]\s*/g, '$1\n- ')
.replace(/([.!?])\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
.replace(/(:)\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
.replace(/(\[(?:\d+\s*,\s*)*\d+\]\.)\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2')
.replace(/([.!?])\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2')
.replace(/(\[(?:\d+\s*,\s*)*\d+\])(?=\s*(?:[A-Z][A-Za-z]+\s+){1,4}(?:deficits?|distress|apnoea|apnea|vomiting|seizures?|signs?|symptoms?|criteria|indications?|risk|oxygen|saturation|dehydration|lethargy|toxicity)\b)/g, '$1\n')
.replace(/([^\n])\s+(#{1,4}\s+)/g, '$1\n\n$2')
.replace(/(#{1,4}\s+[^\n]+?)\s+(-\s+)/g, '$1\n\n$2')
.replace(/(#{1,4}\s+[^\n]+)\n(-\s+)/g, '$1\n\n$2')
.replace(/([^\n])\s+(-\s+(?:Mainstay|Medications|Hospitalization|Other therapies|Prevention|Short-acting|Anticholinergics|Systemic|Adjuncts|Long-term|Infants|Differentiating|Persistent|Severe|Need for|Inadequate)\b)/g, '$1\n$2')
.replace(/([^\n])\s+(-\s+[^\n])/g, '$1\n$2')
.trim()));
}
export function normalizeTableSourceCitationCells(text) {
var lines = String(text || '').split('\n');
for (var i = 0; i < lines.length - 1; i++) {
if (!/^\s*\|.*\|\s*$/.test(lines[i]) || !/^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[i + 1])) continue;
var header = tableCells(lines[i]);
var sourceCols = [];
header.forEach(function(cell, idx) {
if (/^(?:source|sources|source\(s\)|citation|citations|citation\(s\)|reference|references|ref|refs)$/i.test(cell.trim())) sourceCols.push(idx);
});
if (!sourceCols.length) continue;
var j = i + 2;
while (j < lines.length && /^\s*\|.*\|\s*$/.test(lines[j])) {
lines[j] = rewriteTableCells(lines[j], sourceCols, function(cell) {
return normalizeBareCitationCell(cell);
});
j++;
}
i = j - 1;
}
return lines.join('\n');
}
function rewriteTableCells(line, indexes, fn) {
var trimmed = String(line || '').trim();
var leading = /^\|/.test(trimmed);
var trailing = /\|$/.test(trimmed);
var cells = tableCells(line);
indexes.forEach(function(idx) {
if (idx < cells.length) cells[idx] = fn(cells[idx]);
});
return (leading ? '| ' : '') + cells.join(' | ') + (trailing ? ' |' : '');
}
function normalizeBareCitationCell(cell) {
var text = String(cell || '').trim();
if (/^\[(?:\d+\s*,\s*)*\d+\]$/.test(text)) return text;
if (/^\d+(?:\s*,\s*\d+)*$/.test(text)) return '[' + text.replace(/\s*,\s*/g, ', ') + ']';
return text.replace(/(^|\s)(\d+(?:\s*,\s*\d+)+)(?=$|\s)/g, function(match, prefix, nums) {
return prefix + '[' + nums.replace(/\s*,\s*/g, ', ') + ']';
});
}
export function stripOrphanMarkdownMarkers(text) {
return String(text || '')
.replace(/\s*(?:\*\*|__|\*|_)\s*$/g, '')
.replace(/\s*(?:\*\*|__)?\s*(?:Figure|Fig\.)\s*(?:\*\*|__)?\s*$/i, '')
.trim();
}
export function fallbackMarkdown(text) {
var html = escapeHtml(text)
.replace(/^### (.*)$/gm, '<h3>$1</h3>')
.replace(/^## (.*)$/gm, '<h2>$1</h2>')
.replace(/^# (.*)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.split(/\n{2,}/).map(function (block) {
if (/^\s*<h\d/.test(block) && block.indexOf('\n') !== -1) {
var parts = block.split('\n');
return parts.shift() + fallbackMarkdown(parts.join('\n'));
}
if (/^\s*<(h\d|ul|ol|pre|div)/.test(block)) return block;
var lines = block.split('\n');
if (isMarkdownTable(lines)) return renderFallbackTable(lines);
if (lines.some(function (l) { return /^\s*[-*] /.test(l); })) return renderMixedList(lines);
if (lines.every(function (l) { return /^\s*[-*] /.test(l) || !l.trim(); })) {
return '<ul>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*[-*] /, '') + '</li>'; }).join('') + '</ul>';
}
if (lines.every(function (l) { return /^\s*\d+\. /.test(l) || !l.trim(); })) {
return '<ol>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*\d+\. /, '') + '</li>'; }).join('') + '</ol>';
}
return '<p>' + block.replace(/\n/g, '<br>') + '</p>';
}).join('');
return html;
}
function renderMixedList(lines) {
var html = '';
var list = [];
var paragraph = [];
lines.forEach(function(line) {
if (/^\s*[-*] /.test(line)) {
if (paragraph.length) {
html += '<p>' + paragraph.join('<br>') + '</p>';
paragraph = [];
}
list.push(line.replace(/^\s*[-*] /, ''));
return;
}
if (list.length) {
html += '<ul>' + list.map(function(item) { return '<li>' + item + '</li>'; }).join('') + '</ul>';
list = [];
}
if (line.trim()) paragraph.push(line);
});
if (paragraph.length) html += '<p>' + paragraph.join('<br>') + '</p>';
if (list.length) html += '<ul>' + list.map(function(item) { return '<li>' + item + '</li>'; }).join('') + '</ul>';
return html;
}
function isMarkdownTable(lines) {
return lines.length >= 2 && /^\s*\|.*\|\s*$/.test(lines[0]) && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(lines[1]);
}
function renderFallbackTable(lines) {
var header = tableCells(lines[0]);
var rows = lines.slice(2).filter(function(line) { return /^\s*\|.*\|\s*$/.test(line); }).map(tableCells);
return '<table><thead><tr>' + header.map(function(cell) { return '<th>' + cell + '</th>'; }).join('') + '</tr></thead><tbody>' +
rows.map(function(row) { return '<tr>' + row.map(function(cell) { return '<td>' + cell + '</td>'; }).join('') + '</tr>'; }).join('') +
'</tbody></table>';
}
function tableCells(line) {
return String(line || '').trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(function(cell) { return cell.trim(); });
}
export function renderLatexText(text, katex) {
if (!katex) return text;
return String(text || '')
.replace(/\$\$([\s\S]+?)\$\$/g, function(_, expr) { return safeKatex(katex, expr, true); })
.replace(/\\\[([\s\S]+?)\\\]/g, function(_, expr) { return safeKatex(katex, expr, true); })
.replace(/\$([^$\n]+?)\$/g, function(_, expr) { return safeKatex(katex, expr, false); })
.replace(/\\\((.+?)\\\)/g, function(_, expr) { return safeKatex(katex, expr, false); });
}
function safeKatex(katex, expr, displayMode) {
try { return katex.renderToString(expr, { displayMode: displayMode, throwOnError: false }); }
catch (e) { return escapeHtml(expr); }
}
export function escapeHtml(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export function escapeAttr(s) {
return escapeHtml(s).replace(/'/g, '&#39;');
}