pediatric-ai-scribe-v3/public/js/assistant/citations.js
Daniel 0e1e74882e feat: sources are numbered in the order the answer cites them
Borrowed from the quiz app's AI Mode, where validating citations and
ordering them fall out of the same pass: it collects the sources an
answer actually used into an insertion-ordered map, so the list comes
back in first-citation order for free.

Ours listed sources in retrieval order — an order the reader never sees
and has no way to follow. An answer whose first citation was [7] opened
a list that began at [1], so matching a marker to a source meant
hunting. Reference lists in published writing are numbered by first
appearance for exactly this reason.

Cited sources now come first, renumbered by first appearance, and the
markers in the text are rewritten to match. Anything retrieved and not
cited keeps its place after them, labelled "not cited" — the panel is
also a view of what the search returned, which is worth keeping, but it
should not sit among the numbers the answer used.

The marker itself now shows its number instead of the word "src". Every
citation read identically, so the only way to tell one from another was
to hover it — which made the numbered list beneath useless to match
against. The export has shown numbers since the day "src" was
introduced, with no recorded reason for the difference.

Renumbering happens once the whole answer is known, never while
streaming: the order is the order of first citation, so a citation that
has not arrived yet cannot take its place, and numbers would shuffle
under the reader mid-sentence. The text is rewritten in a single pass —
number by number would turn 2 into 1 and then that 1 into whatever 1
maps to.

An invented citation reserves no position and is left exactly as it was.
It is still not turned into a link, and citation_audit still records it;
what matters here is that it cannot push a real source down the list.

Accuracy was already held: a marker with no matching source never
becomes a link. This changes what a reader can do with the ones that are
real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 03:26:17 +02:00

499 lines
23 KiB
JavaScript

// Recognize HTML syntax (including quoted >), not clinical comparisons such as <1 month.
var HTML_TAG_PATTERN = /<!--[\s\S]*?-->|<\/[A-Za-z][A-Za-z0-9-]*\s*>|<[A-Za-z][A-Za-z0-9-]*(?:\s+[A-Za-z_:][A-Za-z0-9_.:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`\x00-\x20]+))?)*\s*\/?>/;
// Mirrors the Open WebUI safeImageUrl allowlist: data:image/* and same-origin
// URLs only. External/protocol-relative image URLs never render as <img>;
// they fall back to their alt text instead.
export function safeImageUrl(src) {
var s = String(src == null ? '' : src).trim();
if (!s) return '';
if (/^data:image\/[a-z0-9.+-]+;/i.test(s)) return s;
if (/^blob:/i.test(s)) return s;
try {
var base = (typeof window !== 'undefined' && window.location && window.location.href) || 'https://synthetic.invalid/';
var url = new URL(s, base);
if (url.protocol === 'http:' || url.protocol === 'https:') return url.origin === new URL(base).origin ? s : '';
if (url.protocol === 'file:' || url.protocol === 'javascript:' || url.protocol === 'data:') return '';
return url.origin === new URL(base).origin ? s : ''; // relative paths resolve to our own origin only
} catch (e) {
return '';
}
}
export function renderAssistantMarkdown(md, sources, options) {
var opts = options || {};
var text = String(md || '')
// The model occasionally writes [src]/[source] placeholders instead of
// real citation numbers. Never invent numbers — drop the tokens; the
// sources panel still lists the actual sources.
.replace(/\[(?:src|source)\]/gi, '');
var embedded = textProtection(text, 'html');
// Literal/embedded content bypasses prose, math and citation rewriting, then
// rejoins the parsed HTML before the single sanitization boundary.
text = protectBlocks(text, opts, function(code, lang) {
lang = String(lang || '').toLowerCase();
var html;
// Percent-encoding keeps Mermaid arrows intact through DOMPurify.
if (lang === 'mermaid') html = '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(encodeURIComponent(code)) + '">Rendering graph...</div>';
else if (lang === 'chart' || lang === 'chartjs') html = '<canvas class="assistant-chart" data-chart="' + escapeAttr(code) + '"></canvas>';
else html = '<pre><code>' + escapeHtml(code) + '</code></pre>';
return embedded.hold(html);
}, true);
var inlineCode = textProtection(text, 'code');
text = text.replace(/(`+)[\s\S]*?\1/g, inlineCode.hold);
var links = textProtection(text, 'links');
text = text.replace(new RegExp(
/!?\[[^\]\n]*\]\([^\n]*?\)|https?:\/\/(?:\\[^\s]|[^\s<>|\\])+|<(?:https?:\/\/|mailto:)[^\s<>]+>|<[^\s<>@]+@[^\s<>@]+>/.source + '|' + HTML_TAG_PATTERN.source,
'gi'), function(match) {
if (match.charAt(0) === '!') {
var imageLink = match.match(/^!\[([^\]\n]*)\]\(([^\n]*?)\)$/);
if (imageLink && !safeImageUrl(imageLink[2])) return links.hold(escapeHtml(imageLink[1] || 'Image'), match);
}
return links.hold(match);
});
text = text.replace(/\\\[((?:\d+\s*,\s*)*\d+)\\\]/g, '[$1]');
text = renderLatexText(text, opts.katex, embedded.hold);
var limited = false;
text = normalizeMarkdownText(text, Object.assign({}, opts, {
onUnrecoverable: function(raw) {
limited = true;
raw = inlineCode.restore(links.restore(embedded.restore(raw, true)));
return embedded.hold('<pre>' + escapeHtml(raw) + '</pre>', raw);
}
}));
text = links.restore(normalizeAdjacentCitationClusters(text, sources || []));
text = inlineCode.restore(text);
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 = embedded.restore(html);
html = wrapTables(html);
if (limited) html += '<p class="assistant-render-notice" role="note">Could not safely recover this flattened table. Stored text is shown unchanged; missing cells or rows cannot be recovered.</p>';
if (opts.notice) html += '<p class="assistant-render-notice" role="note">' + escapeHtml(opts.notice) + '</p>';
return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html;
}
export function wrapTables(html) {
return String(html || '')
.replace(/<table(\s[^>]*)?>/g, '<div class="assistant-table-scroll" tabindex="0" role="region" aria-label="Scrollable table"><table$1>')
.replace(/<\/table>/g, '</table></div>');
}
// A citation marker names a source by its `number`, which dedupeSources assigns
// server-side. Resolving it by array position happens to work today because the
// two agree, but any future filtering or reordering of the list between the
// server and here would silently point citations at the wrong source. Matching
// on the number cannot drift.
function sourceByNumber(sources, n) {
var list = sources || [];
for (var i = 0; i < list.length; i++) {
if (list[i] && Number(list[i].number) === Number(n)) return list[i];
}
// Sources predating the numbering, or a caller passing a bare list.
return list[n - 1];
}
/**
* Sources in the order the answer cites them, renumbered to match.
*
* They arrive in retrieval order, which is an order the reader never sees and
* has no way to follow: an answer whose first citation is [7] opened a list
* that began at [1], so matching a marker to a source meant hunting. Reference
* lists in published writing are numbered by first appearance for exactly this
* reason.
*
* Cited sources come first, renumbered 1..n by first appearance. Anything
* retrieved and not cited keeps its place after them — it is still evidence of
* what the search returned, which is what the panel is for, and it is no longer
* mixed in among the numbers the answer actually used.
*
* Returns new objects. Renumbering in place would corrupt a stored answer whose
* text still holds the original markers.
*/
export function orderSourcesByCitation(text, sources) {
var list = Array.isArray(sources) ? sources : [];
if (!list.length) return { text: String(text || ''), sources: list };
// First appearance wins, and only markers that resolve to a real source
// count — an invented number must not reserve a position in the list.
var order = [];
String(text || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function (_, cluster) {
cluster.split(',').forEach(function (part) {
var n = Number(part.trim());
if (!Number.isInteger(n) || n < 1) return;
if (!sourceByNumber(list, n)) return;
if (order.indexOf(n) === -1) order.push(n);
});
return '';
});
if (!order.length) return { text: String(text || ''), sources: list };
var renumbered = [];
var mapping = {};
order.forEach(function (was, i) {
var source = sourceByNumber(list, was);
mapping[was] = i + 1;
renumbered.push(Object.assign({}, source, { number: i + 1 }));
});
list.forEach(function (source, i) {
var was = Number(source && source.number) || i + 1;
if (mapping[was]) return; // already placed
renumbered.push(Object.assign({}, source, { number: renumbered.length + 1, uncited: true }));
});
// Rewrite the markers in one pass. Doing it number by number would renumber
// something twice — 2 becomes 1, then that 1 becomes whatever 1 maps to.
var rewritten = String(text || '').replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function (match, cluster) {
var nums = cluster.split(',').map(function (p) { return Number(p.trim()); });
if (nums.some(function (n) { return !mapping[n]; })) return match; // leave anything unresolved alone
return '[' + nums.map(function (n) { return mapping[n]; }).join(', ') + ']';
});
return { text: rewritten, sources: renumbered };
}
export function renderCitationLinks(html, sources, options) {
var opts = options || {};
return String(html || '').replace(new RegExp(
/<(pre|code|a)\b[^>]*>[\s\S]*?<\/\1>/.source + '|' + HTML_TAG_PATTERN.source + '|' + /\[((?:\d+\s*,\s*)*\d+)\]/.source,
'gi'), function (match, tag, cluster) {
if (!cluster) return match;
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 !sourceByNumber(sources, n); })) return match;
return nums.map(function (n) {
var source = sourceByNumber(sources, n);
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 : '');
// The number, not "src". Every marker used to read the same, so the only
// way to tell one citation from another was to hover it — and the list
// below is numbered, which made the numbering useless to match against.
// The export has always shown numbers; the screen now agrees with it.
var text = opts.citationLabel === 'text' ? 'src' : String(n);
return '<a class="assistant-cite" href="#' + escapeAttr(opts.citationTargetPrefix || '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 sourceByNumber(sources, n); });
}
function formatCitationCluster(nums) {
nums = Array.from(new Set(nums)).sort(function(a, b) { return a - b; });
return '[' + nums.join(', ') + ']';
}
// Keep Markdown structure and literal syntax out of prose cleanups. The installed
// parser identifies complete blocks; the no-parser path only shields pipe lines.
function textProtection(text, kind) {
var prefix = '\uE000' + (kind || 'markdown') + ':';
while (text.indexOf(prefix) !== -1) prefix += '\uE000';
var values = [];
var originals = [];
var pattern = new RegExp(prefix + '(\\d+)\uE001', 'g');
return {
hold: function(value, original) {
originals.push(typeof original === 'string' ? original : value);
return prefix + (values.push(value) - 1) + '\uE001';
},
restore: function(value, raw) {
var previous;
do {
previous = value;
value = value.replace(pattern, function(_, i) { return (raw ? originals : values)[Number(i)]; });
} while (value !== previous);
return value;
}
};
}
function protectBlocks(text, opts, hold, codeOnly) {
if (opts.marked && typeof opts.marked.lexer === 'function') {
return opts.marked.lexer(text, { gfm: true }).map(function(token) {
if (codeOnly) return token.type === 'code' ? hold(token.text, token.lang) + '\n\n' : token.raw;
if (['space', 'paragraph', 'heading'].includes(token.type)) return token.raw;
return hold(token.type === 'table' ? normalizeTableSourceCitationCells(stripTrailingMarkerLine(token.raw)) :
token.type === 'code' || token.type === 'html' ? token.raw : stripTrailingMarkerLine(token.raw)) + '\n\n';
}).join('');
}
if (opts.markdownIt && typeof opts.markdownIt.parse === 'function') {
var lines = text.split('\n');
var ranges = opts.markdownIt.parse(text, {}).filter(function(token) {
return token.map && (codeOnly ? ['fence', 'code_block'] :
['table_open', 'fence', 'code_block', 'html_block', 'blockquote_open', 'bullet_list_open', 'ordered_list_open']).includes(token.type);
});
var end = 0;
ranges.forEach(function(token) {
if (token.map[0] < end) return;
end = token.map[1];
var raw = lines.slice(token.map[0], end).join('\n');
lines[token.map[0]] = codeOnly ? hold(token.content, token.info) : hold(token.type === 'table_open' ? normalizeTableSourceCitationCells(stripTrailingMarkerLine(raw)) :
['fence', 'code_block', 'html_block'].includes(token.type) ? raw : stripTrailingMarkerLine(raw));
for (var i = token.map[0] + 1; i < end; i++) lines[i] = '';
});
return lines.join('\n');
}
text = text.replace(/^ {0,3}(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)(?:^ {0,3}\1[^\n]*(?:\n|$)|(?![\s\S]))/gm, function(raw, fence, lang, code) {
return codeOnly ? hold(code, lang.trim()) : hold(raw);
});
return text.replace(/(?:^(?: {4}|\t)[^\n]*(?:\n|$))+/gm, function(raw) {
return codeOnly ? hold(raw.replace(/^(?: {4}|\t)/gm, ''), '') : hold(raw);
});
}
function stripTrailingMarkerLine(text) {
return text.replace(/\n[ \t]*(?:\*\*|__|\*|_)[ \t]*\n?$/, '\n');
}
export function normalizeMarkdownText(text, options) {
var opts = options || {};
text = String(text || '').replace(/\r\n/g, '\n');
var protectedText = textProtection(text);
text = protectBlocks(text, opts, protectedText.hold);
// These spans must not become headings/lists or apparent legacy row boundaries.
text = text.replace(/(`+)[\s\S]*?\1|\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)|\$[^$\n]+\$|!?\[[^\]\n]*\]\([^\n]*?\)|https?:\/\/(?:\\[^\s]|[^\s<>|\\])+|\\\|/gi, protectedText.hold);
text = text.split('\n').map(function(line) {
if (!/\|[ \t]*:?-{2,}:?[ \t]*\|/.test(line)) return line;
var recovered = recoverLegacyTableLine(line);
if (recovered !== null) return recovered;
// A standalone delimiter row belongs to existing multiline Markdown.
if (/^[ \t]*\|?[ :|\t-]+$/.test(line)) return line;
var start = line.indexOf('|');
var raw = protectedText.restore(line.slice(start));
var literal = typeof opts.onUnrecoverable === 'function' ? opts.onUnrecoverable(raw) : raw;
return line.slice(0, start) + '\n\n' + protectedText.hold(literal);
}).join('\n');
text = protectBlocks(text, opts, protectedText.hold);
// Also leave incomplete/non-GFM pipe structures alone, rather than guessing.
text = text.replace(/^[^\n]*\|[^\n]*(?:\n[^\n]*\|[^\n]*)*/gm, function(block) {
return protectedText.hold(normalizeTableSourceCitationCells(block));
});
return protectedText.restore(normalizeProse(text));
}
// Only outer-pipe rows separated by "| |", with a header + alignment row and
// identical nonempty cell counts, survive whitespace collapse unambiguously.
// Empty cells, trailing prose, partial rows and mixed separators are not guessed.
function recoverLegacyTableLine(line) {
var start = line.indexOf('|');
var prefix = line.slice(0, start);
var candidate = line.slice(start).trimEnd();
if (start < 0 || !candidate.endsWith('|')) return null;
var rows = candidate.split(/(?<=\|)[ \t]+(?=\|)/);
if (rows.length < 3) return null;
var cells = rows.map(tableCells);
if (cells.some(function(row) { return row.length < 2 || row.some(function(cell) { return !cell; }); })) return null;
function separator(row) { return row.every(function(cell) { return /^:?-{2,}:?$/.test(cell); }); }
var output = [];
for (var i = 0; i < rows.length;) {
if (!cells[i + 1] || !separator(cells[i + 1]) || cells[i].length !== cells[i + 1].length || separator(cells[i])) return null;
var width = cells[i].length;
var end = i + 2;
while (end < rows.length && !(cells[end + 1] && separator(cells[end + 1]))) {
if (cells[end].length !== width || separator(cells[end])) return null;
end++;
}
if (end === i + 2) return null;
output.push(rows.slice(i, end).join('\n'));
i = end;
}
return (prefix.trim() ? prefix + '\n\n' : '') + output.join('\n\n');
}
function normalizeProse(text) {
return stripOrphanMarkdownMarkers(String(text || '')
.replace(/([^\n])\n+\s*(\[(?:\d+\s*,\s*)*\d+\])\s*(?:\n+\s*([.,;:]))?(?=\s*(?:\n|$))/g, '$1 $2$3')
.replace(/([.!?])\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
.replace(/(:)\s*[-–—]\s+(\*\*)?/g, '$1\n- $2')
.replace(/([.!?])\s+(\d+\.\s+[A-Z][A-Za-z][^\n]{0,80})/g, '$1\n$2')
.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')
.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, hold) {
if (!katex && !hold) return text;
function render(raw, expr, display) {
var html = katex ? safeKatex(katex, expr, display) : escapeHtml(raw);
return hold ? hold(html, raw) : html;
}
return String(text || '')
.replace(/\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\$([^$\n]+?)\$|\\\((.+?)\\\)|\\ce\{([^{}\n]*)\}|\\pu\{([^{}\n]*)\}/g, function(raw, dollars, brackets, inline, parens, ce, pu) {
var display = !!(dollars || brackets);
if (dollars || brackets) return render(raw, dollars || brackets, display);
if (inline !== undefined) return render(raw, inline, false);
if (parens !== undefined) return render(raw, parens, false);
if (ce !== undefined) return render(raw, '\\ce{' + ce + '}', false);
if (pu !== undefined) return render(raw, '\\pu{' + pu + '}', false);
return raw;
});
}
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;');
}