Changeset 0.11.0 (#62)

This commit is contained in:
2026-02-25 13:29:15 +00:00
parent d2ec55b16d
commit c9d8e9457e
56 changed files with 5664 additions and 91 deletions

View File

@@ -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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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);
}
}