Translation formatting
LibreTranslate's text mode destroys markdown syntax. Verified against the live
container: table pipes come back as "←", the |---| delimiter row is translated
as prose ("Silencio."), and "**bold**" returns as "** bold**" which no longer
renders. Its html mode leaves tags — and bare [n] markers — completely intact.
Messages and the patient take home are now rendered to HTML, simplified (maths
and UI chrome flattened to text), and translated as HTML. Citation chips are
re-linked from the returned markers afterwards, which is the step the original
html path was missing. A text-mode fallback remains for builds that reject html.
Repeat image generation
Typing "Окей" or "Nice" after an image turn produced another image every time:
the model saw its own "I'll generate an educational image…" in the history and
repeated it. Recognising acknowledgements in every language is not possible, so
the rule is inverted — a short follow-up (<=3 words) that mentions nothing about
a picture does not get the image tool offered at all when the previous assistant
turn produced an image. Terse repeat requests ("again", "ещё", "another one")
still work. The worst case is that a terse question is answered in text.
In-chat images
Generated images render as a 320x240 thumbnail instead of filling the bubble,
and the image itself opens the full-resolution preview.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
423 lines
20 KiB
JavaScript
423 lines
20 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>');
|
|
}
|
|
|
|
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 !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="#' + 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 sources[n - 1]; });
|
|
}
|
|
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
export function escapeAttr(s) {
|
|
return escapeHtml(s).replace(/'/g, ''');
|
|
}
|