All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m50s
Reverts126d7928and2d292d12in 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>
250 lines
11 KiB
JavaScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
export function escapeAttr(s) {
|
|
return escapeHtml(s).replace(/'/g, ''');
|
|
}
|