// Recognize HTML syntax (including quoted >), not clinical comparisons such as <1 month. var HTML_TAG_PATTERN = /|<\/[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 ; // 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 || ''); 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 = '
Rendering graph...
'; else if (lang === 'chart' || lang === 'chartjs') html = ''; else html = '
' + escapeHtml(code) + '
'; 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('
' + escapeHtml(raw) + '
', 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 += '

Could not safely recover this flattened table. Stored text is shown unchanged; missing cells or rows cannot be recovered.

'; if (opts.notice) html += '

' + escapeHtml(opts.notice) + '

'; return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html; } function wrapTables(html) { return String(html || '') .replace(/]*)?>/g, '
') .replace(/<\/table>/g, '
'); } 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 '' + text + ''; }).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, '

$1

') .replace(/^## (.*)$/gm, '

$1

') .replace(/^# (.*)$/gm, '

$1

') .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/`([^`]+)`/g, '$1'); html = html.split(/\n{2,}/).map(function (block) { if (/^\s*' + lines.filter(Boolean).map(function (l) { return '
  • ' + l.replace(/^\s*[-*] /, '') + '
  • '; }).join('') + ''; } if (lines.every(function (l) { return /^\s*\d+\. /.test(l) || !l.trim(); })) { return '
      ' + lines.filter(Boolean).map(function (l) { return '
    1. ' + l.replace(/^\s*\d+\. /, '') + '
    2. '; }).join('') + '
    '; } return '

    ' + block.replace(/\n/g, '
    ') + '

    '; }).join(''); return html; } function renderMixedList(lines) { var html = ''; var list = []; var paragraph = []; lines.forEach(function(line) { if (/^\s*[-*] /.test(line)) { if (paragraph.length) { html += '

    ' + paragraph.join('
    ') + '

    '; paragraph = []; } list.push(line.replace(/^\s*[-*] /, '')); return; } if (list.length) { html += ''; list = []; } if (line.trim()) paragraph.push(line); }); if (paragraph.length) html += '

    ' + paragraph.join('
    ') + '

    '; if (list.length) html += ''; 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 '' + header.map(function(cell) { return ''; }).join('') + '' + rows.map(function(row) { return '' + row.map(function(cell) { return ''; }).join('') + ''; }).join('') + '
    ' + cell + '
    ' + cell + '
    '; } function tableCells(line) { return String(line || '').trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?/g, '>').replace(/"/g, '"'); } export function escapeAttr(s) { return escapeHtml(s).replace(/'/g, '''); }