Revert the clinical assistant markdown changes and fix mermaid rendering only
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m50s

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>
This commit is contained in:
Daniel 2026-08-28 19:55:03 +02:00
parent 2d292d12af
commit 3d4a95fea4
3 changed files with 40 additions and 137 deletions

View file

@ -22,7 +22,10 @@ export function renderAssistantMarkdown(md, sources, options) {
html = renderCitationLinks(html, sources || [], opts);
html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) {
var block = codeBlocks[Number(idx)] || { lang: '', code: '' };
if (block.lang === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(block.code) + '">Rendering graph...</div>';
// 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>';
});
@ -55,13 +58,13 @@ export function renderCitationLinks(html, sources, options) {
export function normalizeAdjacentCitationClusters(text, sources) {
var available = Array.isArray(sources) ? sources : [];
var normalized = String(text || '').replace(/((?:\[(?:\d+\s*,\s*)*\d+\][ \t]*)+)\[((?:\d+\s*,\s*)*\d+)(?=$|[.,;:])/g, function(match, completeClusters, trailingCluster) {
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+\][ \t]*){2,}/g, function(match) {
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);
@ -90,43 +93,21 @@ function formatCitationCluster(nums) {
return '[' + nums.join(', ') + ']';
}
// Repairs the loose formatting models emit: run-together bullets, inline
// headings, numbered items glued onto a sentence.
//
// Two rules make this safe. Table rows are never touched, because inserting a
// newline inside "| a | b |" destroys the row and every row after it. And a
// hyphen only starts a list item when what follows is not a number, because
// "0.15 - 0.6 mg/kg" is a dose range, not a bullet, and splitting it shows the
// reader a different dose.
var TABLE_ROW = /^\s*\|.*\|\s*$/;
var TABLE_DIVIDER = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/;
var LIST_BOUNDARY = /([.!?:;\]])\s+(-\s+(?!\d))/g;
function repairLine(line) {
return line
// A citation followed by a hyphen starts the next item, unless a number follows.
.replace(/(\[(?:\d+\s*,\s*)*\d+\])\s*[-–—]\s+(?!\d)/g, '$1\n- ')
.replace(/([.!?])\s*[-–—]\s+(?!\d)(\*\*)?/g, '$1\n- $2')
.replace(/(:)\s*[-–—]\s+(?!\d)(\*\*)?/g, '$1\n- $2')
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')
// A run-together heading is recognised by what follows the hashes: a capital
// letter means a heading, while "Item # 4" or a "#" table column does not.
.replace(/([^\n])\s+(#{1,4}\s+(?=[A-Z]))/g, '$1\n\n$2')
.replace(/(#{1,4}\s+[^\n]+?)\s+(-\s+(?!\d))/g, '$1\n\n$2')
.replace(LIST_BOUNDARY, '$1\n$2');
}
export function normalizeMarkdownText(text) {
var lines = String(text || '').replace(/\r\n/g, '\n').split('\n');
var repaired = lines.map(function(line, index) {
if (TABLE_ROW.test(line) || TABLE_DIVIDER.test(line)) return line;
// A line directly above a divider is a table header even without pipes.
if (index + 1 < lines.length && TABLE_DIVIDER.test(lines[index + 1])) return line;
return repairLine(line);
}).join('\n');
return stripOrphanMarkdownMarkers(normalizeTableSourceCitationCells(repaired).trim());
.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) {
@ -172,18 +153,10 @@ function normalizeBareCitationCell(cell) {
}
export function stripOrphanMarkdownMarkers(text) {
var out = String(text || '')
.replace(/\s*(?:\*\*|__)?\s*(?:Figure|Fig\.)\s*(?:\*\*|__)?\s*$/i, '');
// Remove a trailing marker only when it has no partner, so "…**Monitor
// closely**" keeps its closing pair while a dangling "**" is still cleaned up.
var trailing = out.match(/(\*\*|__|\*|_)\s*$/);
if (trailing) {
var marker = trailing[1];
var body = out.slice(0, out.length - trailing[0].length);
var occurrences = body.split(marker).length - 1;
if (occurrences % 2 === 0) out = body;
}
return out.trim();
return String(text || '')
.replace(/\s*(?:\*\*|__|\*|_)\s*$/g, '')
.replace(/\s*(?:\*\*|__)?\s*(?:Figure|Fig\.)\s*(?:\*\*|__)?\s*$/i, '')
.trim();
}
export function fallbackMarkdown(text) {
@ -259,9 +232,7 @@ export function renderLatexText(text, katex) {
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(match, expr) {
return /[\\^_{}]|\\frac|\\times|\\le|\\ge/.test(expr) ? safeKatex(katex, expr, false) : match;
})
.replace(/\$([^$\n]+?)\$/g, function(_, expr) { return safeKatex(katex, expr, false); })
.replace(/\\\((.+?)\\\)/g, function(_, expr) { return safeKatex(katex, expr, false); });
}

View file

@ -432,13 +432,17 @@ import {
}
function renderEmbeddedBlocks(root) {
function mermaidSource(el) {
var raw = el.getAttribute('data-mermaid') || '';
try { return decodeURIComponent(raw); } catch (e) { return raw; }
}
root.querySelectorAll('[data-mermaid]').forEach(function (el) {
ensureMermaid().then(function () {
if (!window.mermaid) { el.textContent = el.getAttribute('data-mermaid'); return; }
if (!window.mermaid) { el.textContent = mermaidSource(el); return; }
var id = 'assistant-mermaid-' + Math.random().toString(16).slice(2);
window.mermaid.render(id, el.getAttribute('data-mermaid') || '')
window.mermaid.render(id, mermaidSource(el))
.then(function (out) { el.innerHTML = out.svg || ''; })
.catch(function () { el.textContent = el.getAttribute('data-mermaid') || ''; });
.catch(function () { el.textContent = mermaidSource(el); });
});
});
root.querySelectorAll('canvas[data-chart]').forEach(function (canvas) {

View file

@ -253,86 +253,14 @@ test('clinical assistant streams long table answers as lightweight text before f
assert.match(source, /pipeRows >= 8/);
});
test('numeric ranges written with spaced hyphens are not turned into bullets', async () => {
const { normalizeMarkdownText } = await loadCitationModule();
// A dose range split across a line break reads as a different dose.
for (const text of [
'Give dexamethasone 0.15 - 0.6 mg/kg orally.',
'Target SpO2 92 - 96% on room air.',
'Aim for pH 7.35 - 7.45.',
'Ages 2 - 5 years.',
'Use 5 - 10 mL/kg boluses.'
]) {
assert.equal(normalizeMarkdownText(text), text, text);
}
});
test('a hyphen after sentence punctuation still starts a list item', async () => {
const { normalizeMarkdownText } = await loadCitationModule();
assert.equal(normalizeMarkdownText('Treatment options. - Dexamethasone first.'), 'Treatment options.\n- Dexamethasone first.');
assert.equal(normalizeMarkdownText('Options: - Dexamethasone'), 'Options:\n- Dexamethasone');
assert.equal(normalizeMarkdownText('Works well [1] - Dexamethasone'), 'Works well [1]\n- Dexamethasone');
assert.equal(normalizeMarkdownText('Intro\n- one\n- two'), 'Intro\n- one\n- two');
});
test('a bold phrase at the end of an answer keeps its closing marker', async () => {
const { stripOrphanMarkdownMarkers } = await loadCitationModule();
assert.equal(stripOrphanMarkdownMarkers('Give oxygen. **Monitor closely**'), 'Give oxygen. **Monitor closely**');
assert.equal(stripOrphanMarkdownMarkers('Note the *caveat*'), 'Note the *caveat*');
// A genuinely unpaired marker is still removed.
assert.equal(stripOrphanMarkdownMarkers('Ends with bold **'), 'Ends with bold');
});
test('dollar amounts are not rendered as mathematics', async () => {
const { renderLatexText } = await loadCitationModule();
const katex = { renderToString: (expr) => '<KATEX>' + expr + '</KATEX>' };
assert.equal(renderLatexText('Costs $5 to $10 per dose', katex), 'Costs $5 to $10 per dose');
// Real mathematics still renders.
assert.equal(renderLatexText('SpO$_2$ target', katex), 'SpO<KATEX>_2</KATEX> target');
assert.equal(renderLatexText('Use $\\frac{1}{2}$ dose', katex), 'Use <KATEX>\\frac{1}{2}</KATEX> dose');
});
test('table rows are never rewritten by the bullet and heading repairs', async () => {
const { normalizeMarkdownText } = await loadCitationModule();
// A newline inserted inside a row destroys that row and every row after it.
for (const text of [
'| Dexamethasone | 0.6 mg/kg PO (max 16 mg) - single dose | 1 |',
'| Item # | Dose |',
'| Hypoxia | SpO2 <90 \\| escalate | 1 |',
'| Drug | Dose |\n|---|---|\n| Dex | 0.6 mg/kg (max 16 mg) - single dose |'
]) {
assert.equal(normalizeMarkdownText(text), text, text);
}
});
test('ranges survive in headings, after citations, and beside a hash', async () => {
const { normalizeMarkdownText } = await loadCitationModule();
for (const text of [
'### Dexamethasone 0.15 - 0.6 mg/kg PO once',
'## Target SpO2 92 - 96%',
'Magnesium 40 mg/kg IV over 20 min [1] - 50 mg/kg is an alternative.',
'Room # 4 and bed # 2'
]) {
assert.equal(normalizeMarkdownText(text), text, text);
}
});
test('the loose-formatting repairs still fire where they were meant to', async () => {
const { normalizeMarkdownText } = await loadCitationModule();
assert.equal(normalizeMarkdownText('Treatment options. - Dexamethasone first.'), 'Treatment options.\n- Dexamethasone first.');
assert.equal(normalizeMarkdownText('Options: - Dexamethasone'), 'Options:\n- Dexamethasone');
assert.equal(normalizeMarkdownText('Works well [1] - Dexamethasone helps'), 'Works well [1]\n- Dexamethasone helps');
assert.equal(normalizeMarkdownText('That is all. ## Management'), 'That is all.\n\n## Management');
assert.equal(normalizeMarkdownText('## Management - Dexamethasone first'), '## Management\n\n- Dexamethasone first');
});
test('adjacent citations merge only on the same line', async () => {
const { normalizeAdjacentCitationClusters } = await loadCitationModule();
const sources = [{ title: 'A' }, { title: 'B' }, { title: 'C' }];
assert.equal(normalizeAdjacentCitationClusters('Works [1] [2] [3].', sources), 'Works [1, 2, 3].');
// Merging across a break moved a citation onto a claim it never supported.
assert.equal(
normalizeAdjacentCitationClusters('Give ceftriaxone 50 mg/kg [1]\n\n[2] Vancomycin only if MRSA.', sources),
'Give ceftriaxone 50 mg/kg [1]\n\n[2] Vancomycin only if MRSA.'
);
test('mermaid source survives the sanitiser and round-trips', async () => {
const { renderAssistantMarkdown } = await loadCitationModule();
const flowchart = 'graph TD; A[Start]-->B[Give O2];';
const html = renderAssistantMarkdown('```mermaid\n' + flowchart + '\n```', []);
const attr = html.match(/data-mermaid="([^"]*)"/);
assert.ok(attr, 'the placeholder must carry the diagram source');
// Encoded, because DOMPurify strips an attribute whose value contains "-->"
// and every flowchart has one, which left diagrams stuck on their placeholder.
assert.ok(!attr[1].includes('-->'), 'the stored value must not contain a raw arrow');
assert.equal(decodeURIComponent(attr[1].replace(/&amp;/g, '&')).trim(), flowchart);
});