Changeset 0.11.0 (#62)
This commit is contained in:
@@ -48,6 +48,15 @@ function formatMessage(content) {
|
||||
}
|
||||
|
||||
function _formatMarked(text) {
|
||||
// ── Unwrap outer ```markdown wrappers ────────────────────────
|
||||
// LLMs frequently wrap entire responses in ```markdown ... ``` which is
|
||||
// semantically "this content IS markdown". Standard markdown doesn't support
|
||||
// nested fences with the same delimiter, so an inner ```mermaid ... ```
|
||||
// prematurely closes the outer block, splitting the content into broken
|
||||
// fragments. Fix: if the outer fence is ```markdown/```md and contains
|
||||
// nested fences, strip the wrapper and render contents as markdown directly.
|
||||
text = _unwrapMarkdownFence(text);
|
||||
|
||||
// Close any unclosed code fences to prevent raw HTML injection during
|
||||
// streaming (closing ``` hasn't arrived yet) or when the model forgets
|
||||
// to close a fence. An odd number of ``` markers means one is unclosed.
|
||||
@@ -83,7 +92,25 @@ function _formatMarked(text) {
|
||||
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
|
||||
const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
|
||||
const langMatch = attrs.match(/class="language-(\w+)"/);
|
||||
const lang = langMatch ? langMatch[1] : '';
|
||||
const lang = langMatch ? langMatch[1].toLowerCase() : '';
|
||||
|
||||
// Extension hook: let extensions claim specific code blocks (mermaid, latex, etc.)
|
||||
// Extensions mark blocks with data-ext-render for post-render DOM processing.
|
||||
if (lang && typeof Extensions !== 'undefined' && Extensions.hasRenderers('block')) {
|
||||
try {
|
||||
const fakeContainer = document.createElement('div');
|
||||
const decoded = _decodeHTML(code);
|
||||
const handled = Extensions.runBlockRenderers(lang, decoded, fakeContainer);
|
||||
if (handled && fakeContainer.innerHTML) {
|
||||
return `<div class="ext-rendered" data-ext-block="${codeId}">${fakeContainer.innerHTML}</div>`;
|
||||
}
|
||||
if (handled && !fakeContainer.innerHTML) {
|
||||
console.warn(`[Extensions] Block renderer matched lang="${lang}" but produced empty output`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Block renderer hook error for lang="${lang}":`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Count lines for collapse decision
|
||||
const lineCount = (code.match(/\n/g) || []).length + 1;
|
||||
@@ -143,6 +170,56 @@ function _formatBasic(text) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Markdown Fence Unwrapper ──────────────────
|
||||
// LLMs often wrap entire responses in ```markdown ... ```. This is semantically
|
||||
// a no-op ("this markdown IS markdown") but breaks parsing when the content
|
||||
// contains nested code fences — the inner closing ``` prematurely terminates
|
||||
// the outer block, splitting the response into fragments.
|
||||
//
|
||||
// Strategy: if the text is wrapped in ```markdown/```md and the body contains
|
||||
// at least one nested ``` fence, strip the outer wrapper. If there are no
|
||||
// nested fences, leave it alone (the user/LLM may genuinely want to show
|
||||
// markdown source in a code block).
|
||||
function _unwrapMarkdownFence(text) {
|
||||
// The text may start with THINK_PLACEHOLDER_xxx blocks (extracted earlier).
|
||||
// We need to find the ```markdown fence after any placeholders/whitespace.
|
||||
const openMatch = text.match(/^((?:\s|THINK_PLACEHOLDER_\w+)*)(```(?:markdown|md)\s*\n)/i);
|
||||
if (!openMatch) return text;
|
||||
|
||||
const prefix = openMatch[1]; // placeholders + whitespace before the fence
|
||||
const fence = openMatch[2]; // the ```markdown\n itself
|
||||
const afterOpen = prefix.length + fence.length;
|
||||
|
||||
// Find the LAST bare ``` on its own line — this is the outer closing fence.
|
||||
// There may be trailing text (LLM explanations) after it.
|
||||
let closeIdx = -1;
|
||||
const searchRegex = /\n```[ \t]*(?:\n|$)/g;
|
||||
let m;
|
||||
while ((m = searchRegex.exec(text)) !== null) {
|
||||
closeIdx = m.index;
|
||||
}
|
||||
if (closeIdx < afterOpen) return text;
|
||||
|
||||
// Extract inner content and any trailing text after the close
|
||||
const inner = text.slice(afterOpen, closeIdx);
|
||||
const trailing = text.slice(closeIdx).replace(/^\n```[ \t]*\n?/, '\n');
|
||||
|
||||
// Only unwrap if the inner content contains nested fences —
|
||||
// otherwise this might be intentional "show me the markdown source"
|
||||
if (/^```/m.test(inner)) {
|
||||
return prefix + inner + trailing;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
// Decode HTML entities in code block content (for extension renderers that need raw text)
|
||||
function _decodeHTML(html) {
|
||||
const txt = document.createElement('textarea');
|
||||
txt.innerHTML = html;
|
||||
return txt.value;
|
||||
}
|
||||
|
||||
// Heuristic: does this code block look like HTML?
|
||||
function _looksLikeHTML(code) {
|
||||
const decoded = code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
@@ -351,3 +428,13 @@ function _relativeTime(dateStr) {
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run extension post-renderers on a container after innerHTML is set.
|
||||
* Safe to call even if extensions aren't loaded — no-ops gracefully.
|
||||
*/
|
||||
function runExtensionPostRender(container) {
|
||||
if (typeof Extensions !== 'undefined' && Extensions._loaded) {
|
||||
Extensions.runPostRenderers(container);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user