mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 09:45:24 -04:00
fix(chat): stop code-block button flicker during streaming (#3023)
Render streamed markdown incrementally (freeze finalized blocks, re-render only the growing tail) instead of re-rendering the whole message every token, which recreated every <pre> and dropped CSS :hover.
This commit is contained in:
+20
-63
@@ -23,6 +23,7 @@ import * as emailInbox from './emailInbox.js';
|
||||
import codeRunnerModule from './codeRunner.js';
|
||||
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js';
|
||||
import createResearchSynapse from './researchSynapse.js';
|
||||
import { createStreamRenderer } from './streamingRenderer.js';
|
||||
const RESEARCH_TIMEOUT_MS = 360000;
|
||||
const DEFAULT_TIMEOUT_MS = 120000;
|
||||
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
||||
@@ -1167,9 +1168,6 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
let _liveThinkToggle = null;
|
||||
let _liveThinkDomId = null;
|
||||
|
||||
// Offscreen measurement div — reused across renders
|
||||
let _measureDiv = null;
|
||||
|
||||
function _replyAfterClosedThinking(text) {
|
||||
const closeRe = /<\/(?:think(?:ing)?|thought)>|<channel\|>/gi;
|
||||
let match = null;
|
||||
@@ -1224,19 +1222,18 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
}
|
||||
}
|
||||
if (replyTrimmed) {
|
||||
const replyHtml = markdownModule.mdToHtml(markdownModule.squashOutsideCode(replyTrimmed));
|
||||
const prevLen = liveReply._prevTextLen || 0;
|
||||
liveReply.innerHTML = replyHtml;
|
||||
_fadeNewTokens(liveReply, prevLen);
|
||||
liveReply._prevTextLen = liveReply.textContent.length;
|
||||
if (window.hljs) liveReply.querySelectorAll('pre code').forEach((b) => window.hljs.highlightElement(b));
|
||||
const r = liveReply._streamRenderer ||
|
||||
(liveReply._streamRenderer = createStreamRenderer(liveReply, {
|
||||
render: (t) => markdownModule.mdToHtml(markdownModule.squashOutsideCode(t)),
|
||||
hljs: window.hljs,
|
||||
}));
|
||||
r.update(replyTrimmed);
|
||||
}
|
||||
// Reply empty or not — preserve thinking bar, don't fall through to full re-render
|
||||
uiModule.scrollHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
const prevLen = contentEl._prevTextLen || 0;
|
||||
// If thinking is still streaming (unclosed <think>), show indicator instead of raw text
|
||||
if (markdownModule.hasUnclosedThinkTag && markdownModule.hasUnclosedThinkTag(dt)) {
|
||||
const thinkStart = dt.search(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i);
|
||||
@@ -1250,66 +1247,26 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
contentEl.innerHTML =
|
||||
'<div class="thinking-section"><div class="thinking-header"><div class="thinking-header-left">Thinking' +
|
||||
(lines > 1 ? ` (${lines} lines)` : '') + '</div></div></div>';
|
||||
contentEl._prevTextLen = 0;
|
||||
// The stream renderer self-heals when it next sees this overwritten
|
||||
// container (streamingRenderer.js), so no explicit reset is needed here.
|
||||
uiModule.scrollHistory();
|
||||
return;
|
||||
}
|
||||
const html = markdownModule.processWithThinking(markdownModule.squashOutsideCode(dt));
|
||||
|
||||
// Smooth expand only for regular chat text (not thinking/agent blocks)
|
||||
const _hasThinking = html.includes('thinking-section');
|
||||
const _isAgentRound = roundHolder !== holder;
|
||||
if (!_hasThinking && !_isAgentRound) {
|
||||
// Render into offscreen clone to measure new height before swapping
|
||||
if (!_measureDiv) {
|
||||
_measureDiv = document.createElement('div');
|
||||
_measureDiv.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;z-index:-1;';
|
||||
}
|
||||
_measureDiv.style.width = contentEl.offsetWidth + 'px';
|
||||
_measureDiv.className = contentEl.className;
|
||||
_measureDiv.innerHTML = html;
|
||||
contentEl.parentNode.appendChild(_measureDiv);
|
||||
const measuredH = _measureDiv.offsetHeight;
|
||||
_measureDiv.remove();
|
||||
const curMin = parseFloat(contentEl.style.minHeight) || 0;
|
||||
contentEl.style.minHeight = Math.max(curMin, measuredH) + 'px';
|
||||
} else {
|
||||
contentEl.style.minHeight = '';
|
||||
}
|
||||
|
||||
contentEl.innerHTML = html;
|
||||
_fadeNewTokens(contentEl, prevLen);
|
||||
contentEl._prevTextLen = contentEl.textContent.length;
|
||||
if (window.hljs) contentEl.querySelectorAll('pre code').forEach((b) => window.hljs.highlightElement(b));
|
||||
// Incremental streaming render: freeze finalized blocks, re-render only the
|
||||
// growing tail, and highlight each code block once on completion. This is
|
||||
// what keeps code-block hover buttons from flickering and avoids the O(N^2)
|
||||
// re-parse/re-highlight of the whole message on every token.
|
||||
// See streamingRenderer.js / streamingSegmenter.js.
|
||||
const renderer = contentEl._streamRenderer ||
|
||||
(contentEl._streamRenderer = createStreamRenderer(contentEl, {
|
||||
render: (t) => markdownModule.processWithThinking(markdownModule.squashOutsideCode(t)),
|
||||
hljs: window.hljs,
|
||||
}));
|
||||
renderer.update(dt);
|
||||
uiModule.scrollHistory();
|
||||
};
|
||||
|
||||
// Walk text nodes, skip past `prevLen` characters of old text,
|
||||
// wrap everything after that in <span class="token-new"> for fade-in
|
||||
function _fadeNewTokens(container, prevLen) {
|
||||
if (!prevLen) return; // First chunk — skip, whole msg already has entrance anim
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
||||
let charCount = 0;
|
||||
const toWrap = [];
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
const len = node.textContent.length;
|
||||
if (charCount + len <= prevLen) { charCount += len; continue; }
|
||||
const splitAt = charCount < prevLen ? prevLen - charCount : 0;
|
||||
toWrap.push({ node, splitAt });
|
||||
charCount += len;
|
||||
}
|
||||
for (const { node, splitAt } of toWrap) {
|
||||
const parent = node.parentNode;
|
||||
if (!parent || parent.closest('pre, .think-content')) continue;
|
||||
const target = splitAt > 0 ? node.splitText(splitAt) : node;
|
||||
const span = document.createElement('span');
|
||||
span.className = 'token-new';
|
||||
parent.replaceChild(span, target);
|
||||
span.appendChild(target);
|
||||
}
|
||||
}
|
||||
|
||||
let _nextIsError = false;
|
||||
let _streamSawDone = false;
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// streamingRenderer.js
|
||||
//
|
||||
// The DOM shell for incremental streaming markdown rendering. One instance owns
|
||||
// the DOM of one streaming assistant message and is the only thing that writes to
|
||||
// it while it streams.
|
||||
//
|
||||
// It keeps the message as two regions, separated by an invisible comment marker so
|
||||
// the rendered blocks are direct children of the container (no wrapper elements to
|
||||
// disturb CSS):
|
||||
//
|
||||
// [ finalized block, frozen ][ finalized block, frozen ] <!--tail--> [ live tail ]
|
||||
//
|
||||
// - Finalized blocks are rendered once and never touched again — so code-block
|
||||
// hover buttons can't flicker and code is highlighted exactly once.
|
||||
// - The live tail (the still-growing trailing block) is re-rendered each token,
|
||||
// except an open code fence, which streams in append-mode (text appended to a
|
||||
// stable <pre>, highlighted once when it closes).
|
||||
//
|
||||
// All the "is this safe to freeze?" logic lives in the pure segmenter; this file
|
||||
// is deliberately mechanical. If anything throws, it latches into a full-re-render
|
||||
// fallback so a bug can never produce broken output — only today's behavior.
|
||||
|
||||
import { splitFinalized, describeOpenFence } from './streamingSegmenter.js';
|
||||
|
||||
// Compile-time escape hatch: set to false to force the plain full-re-render path.
|
||||
// (The per-instance try/catch `degraded` fallback below is the runtime safety net.)
|
||||
const ENABLED = true;
|
||||
|
||||
export function createStreamRenderer(contentEl, { render, hljs } = {}) {
|
||||
let started = false;
|
||||
let tailMarker = null; // finalized nodes precede it; live-tail nodes follow it
|
||||
let committedLen = 0; // chars of source already frozen
|
||||
let lastText = ''; // most recent full text (for finalize)
|
||||
let tailShownLen = 0; // rendered-text length of the live tail (drives token fade)
|
||||
let appendMode = null; // { codeText: Text, appendedLen } while an open fence streams
|
||||
let degraded = !ENABLED; // true once we fall back to full re-render
|
||||
|
||||
function start() {
|
||||
contentEl.textContent = '';
|
||||
tailMarker = document.createComment('tail');
|
||||
contentEl.appendChild(tailMarker);
|
||||
started = true;
|
||||
}
|
||||
|
||||
function highlight(root) {
|
||||
if (hljs) root.querySelectorAll('pre code').forEach((b) => hljs.highlightElement(b));
|
||||
}
|
||||
|
||||
function clearTail() {
|
||||
while (tailMarker.nextSibling) tailMarker.nextSibling.remove();
|
||||
}
|
||||
|
||||
// Render `src` and freeze the nodes before the tail marker. Highlighting happens
|
||||
// here, once, on the detached fragment before the nodes are ever shown.
|
||||
function freeze(src) {
|
||||
const holder = document.createElement('div');
|
||||
holder.innerHTML = render(src);
|
||||
highlight(holder);
|
||||
while (holder.firstChild) contentEl.insertBefore(holder.firstChild, tailMarker);
|
||||
}
|
||||
|
||||
// Re-render the live tail. An open trailing fence streams in append-mode.
|
||||
function renderTail(tailText) {
|
||||
const fence = tailText ? describeOpenFence(tailText) : null;
|
||||
if (fence) {
|
||||
appendOpenFence(tailText, fence);
|
||||
return;
|
||||
}
|
||||
appendMode = null;
|
||||
clearTail();
|
||||
if (!tailText) {
|
||||
tailShownLen = 0;
|
||||
return;
|
||||
}
|
||||
const holder = document.createElement('div');
|
||||
holder.innerHTML = render(tailText);
|
||||
fadeNewText(holder, tailShownLen);
|
||||
tailShownLen = holder.textContent.length;
|
||||
while (holder.firstChild) contentEl.appendChild(holder.firstChild);
|
||||
}
|
||||
|
||||
// Stream the body of an unterminated code fence by appending only the new
|
||||
// characters to a stable <pre><code> text node — no re-parse, no re-highlight.
|
||||
function appendOpenFence(tailText, fence) {
|
||||
if (!appendMode) {
|
||||
clearTail();
|
||||
const pre = document.createElement('pre');
|
||||
const code = document.createElement('code');
|
||||
if (fence.lang) code.className = `language-${fence.lang}`;
|
||||
const textNode = document.createTextNode('');
|
||||
code.appendChild(textNode);
|
||||
pre.appendChild(code);
|
||||
contentEl.appendChild(pre);
|
||||
appendMode = { codeText: textNode, appendedLen: 0 };
|
||||
tailShownLen = 0; // code is never faded; prose after the fence fades fresh
|
||||
}
|
||||
const code = tailText.slice(fence.contentStart);
|
||||
if (code.length > appendMode.appendedLen) {
|
||||
appendMode.codeText.appendData(code.slice(appendMode.appendedLen));
|
||||
appendMode.appendedLen = code.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap tail text past `prevLen` characters in <span class="token-new"> for the
|
||||
// streaming fade-in. Skips code (<pre>) and thinking blocks (.thinking-content).
|
||||
// Note: the original chat.js helper checked `.think-content`, a class that exists
|
||||
// nowhere in the app, so thinking text used to fade; matching the real
|
||||
// `.thinking-content` corrects that. Operates on the detached fragment before insertion.
|
||||
function fadeNewText(container, prevLen) {
|
||||
if (!prevLen) return;
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
||||
let count = 0;
|
||||
const toWrap = [];
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
const len = node.textContent.length;
|
||||
if (count + len <= prevLen) {
|
||||
count += len;
|
||||
continue;
|
||||
}
|
||||
toWrap.push({ node, splitAt: count < prevLen ? prevLen - count : 0 });
|
||||
count += len;
|
||||
}
|
||||
for (const { node, splitAt } of toWrap) {
|
||||
const parent = node.parentNode;
|
||||
if (!parent || parent.closest('pre, .thinking-content')) continue;
|
||||
const target = splitAt > 0 ? node.splitText(splitAt) : node;
|
||||
const span = document.createElement('span');
|
||||
span.className = 'token-new';
|
||||
parent.replaceChild(span, target);
|
||||
span.appendChild(target);
|
||||
}
|
||||
}
|
||||
|
||||
function fullRender(fullText) {
|
||||
contentEl.innerHTML = render(fullText);
|
||||
highlight(contentEl);
|
||||
}
|
||||
|
||||
// Render the latest full source text.
|
||||
//
|
||||
// PRECONDITION: callers must pass append-only text — each call's `fullText` must
|
||||
// extend the previous one with the already-seen prefix UNCHANGED. Finalized
|
||||
// blocks are frozen and never re-rendered, so a feed that rewrites earlier text
|
||||
// would leave stale frozen blocks (corrected only by the next full re-render).
|
||||
// chat.js satisfies this: its stripToolBlocks output only strips not-yet-finalized
|
||||
// trailing tool syntax, never text that has already been frozen.
|
||||
function update(fullText) {
|
||||
lastText = fullText;
|
||||
if (degraded) {
|
||||
fullRender(fullText);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Self-heal: if our DOM was replaced out from under us — chat.js writes
|
||||
// contentEl.innerHTML directly for thinking indicators and tool blocks, and
|
||||
// finalize() removes the marker — our tail marker is no longer a child of the
|
||||
// container. Rebuild from scratch so we never append onto foreign content or
|
||||
// touch a detached marker.
|
||||
if (started && (!tailMarker || tailMarker.parentNode !== contentEl)) {
|
||||
started = false;
|
||||
committedLen = 0;
|
||||
tailShownLen = 0;
|
||||
appendMode = null;
|
||||
}
|
||||
if (!started) start();
|
||||
const next = splitFinalized(fullText, render, committedLen);
|
||||
if (next > committedLen) {
|
||||
freeze(fullText.slice(committedLen, next));
|
||||
committedLen = next;
|
||||
appendMode = null; // whatever was streaming is now frozen
|
||||
tailShownLen = 0;
|
||||
}
|
||||
renderTail(fullText.slice(committedLen));
|
||||
} catch (err) {
|
||||
degraded = true;
|
||||
console.error('streamingRenderer: falling back to full render', err);
|
||||
fullRender(fullText);
|
||||
}
|
||||
}
|
||||
|
||||
// Stream finished: freeze whatever is left canonically and flatten away the
|
||||
// marker so the container holds exactly what a single full render would produce.
|
||||
// chat.js currently re-renders the finished message from source for its own
|
||||
// reasons and so doesn't call this, but it completes the renderer's lifecycle and
|
||||
// is exercised by the tests.
|
||||
function finalize() {
|
||||
if (degraded) return;
|
||||
try {
|
||||
if (!started) start();
|
||||
clearTail();
|
||||
appendMode = null;
|
||||
const rest = lastText.slice(committedLen);
|
||||
if (rest.trim()) freeze(rest);
|
||||
tailMarker.remove();
|
||||
tailMarker = null;
|
||||
committedLen = lastText.length;
|
||||
} catch (err) {
|
||||
degraded = true;
|
||||
console.error('streamingRenderer: falling back to full render', err);
|
||||
fullRender(lastText);
|
||||
}
|
||||
}
|
||||
|
||||
return { update, finalize };
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// streamingSegmenter.js
|
||||
//
|
||||
// Pure logic for incremental ("block-at-a-time") streaming markdown rendering.
|
||||
//
|
||||
// While an assistant message streams in, re-rendering the whole accumulated
|
||||
// markdown on every token is wasteful (O(N^2)) and recreates DOM nodes, which
|
||||
// makes code-block hover buttons flicker. The fix is to FREEZE the leading part
|
||||
// of the message that can no longer change, and only re-render the growing tail.
|
||||
//
|
||||
// This module answers the one hard question that makes freezing safe:
|
||||
//
|
||||
// Given the full markdown received so far, how many leading characters can
|
||||
// be finalized without changing the rendered output?
|
||||
//
|
||||
// The contract callers rely on (`render` is the canonical markdown renderer):
|
||||
//
|
||||
// const n = splitFinalized(text, render);
|
||||
// render(text.slice(0, n)) + render(text.slice(n)) === render(text)
|
||||
//
|
||||
// The module is intentionally DOM-free and renderer-agnostic so it can be unit
|
||||
// tested in isolation and reused for any markdown renderer with no long-range
|
||||
// cross-block dependencies (no reference-style links / footnotes).
|
||||
//
|
||||
// Known limitations (both bounded by the same mitigation):
|
||||
// - cutIsRenderSafe proves only PRESENT-tense equivalence. If the renderer pairs
|
||||
// an inline delimiter across a blank line (e.g. markdown.js will turn
|
||||
// `*a\n\nb*` into emphasis spanning two paragraphs), a block frozen before the
|
||||
// closing delimiter arrives can disagree with the final full render.
|
||||
// - afterClosedFence boundaries are trusted without the equivalence check, so a
|
||||
// fence the real renderer parses differently (e.g. a stray 4-backtick line) can
|
||||
// be mis-detected as a close.
|
||||
// Both only occur for input the renderer itself handles oddly, and both are
|
||||
// transient: chat.js re-renders the finished message from source, so the settled
|
||||
// output is always canonical.
|
||||
|
||||
// A fenced-code delimiter line: up to 3 leading spaces, then >=3 backticks or
|
||||
// tildes, then an optional info string.
|
||||
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
||||
|
||||
/**
|
||||
* Scan `text` starting at `fromOffset` — which MUST be at top level (callers only
|
||||
* ever advance to a finalized boundary, never into a fence) — and collect the
|
||||
* candidate cut points.
|
||||
*
|
||||
* @returns {{ boundaries: Array<{offset:number, afterClosedFence:boolean}>, inFence:boolean }}
|
||||
* - A blank-line run at top level yields a boundary at the start of the next
|
||||
* non-blank line (`afterClosedFence: false`).
|
||||
* - A fence close yields a boundary just past the closing fence line
|
||||
* (`afterClosedFence: true`) — such a cut is unconditionally safe, since
|
||||
* nothing can ever merge into a completed code block.
|
||||
*/
|
||||
function findBoundaries(text, fromOffset) {
|
||||
const boundaries = [];
|
||||
const n = text.length;
|
||||
let inFence = false;
|
||||
let fenceMarker = '';
|
||||
let i = fromOffset;
|
||||
|
||||
while (i < n) {
|
||||
const nl = text.indexOf('\n', i);
|
||||
const lineEnd = nl === -1 ? n : nl;
|
||||
const afterNl = nl === -1 ? n : nl + 1;
|
||||
const line = text.slice(i, lineEnd);
|
||||
const fence = line.match(FENCE_RE);
|
||||
|
||||
if (fence) {
|
||||
const marker = fence[1];
|
||||
if (!inFence) {
|
||||
inFence = true;
|
||||
fenceMarker = marker;
|
||||
} else if (
|
||||
marker[0] === fenceMarker[0] &&
|
||||
marker.length >= fenceMarker.length &&
|
||||
fence[2].trim() === '' // a closing fence carries no info string
|
||||
) {
|
||||
inFence = false;
|
||||
fenceMarker = '';
|
||||
boundaries.push({ offset: afterNl, afterClosedFence: true });
|
||||
}
|
||||
i = afterNl;
|
||||
} else if (!inFence && line.trim() === '') {
|
||||
// Consume the entire run of blank lines; the boundary is the start of the
|
||||
// next non-blank line so the finalized side owns the separator and the tail
|
||||
// starts clean.
|
||||
let j = afterNl;
|
||||
while (j < n) {
|
||||
const nl2 = text.indexOf('\n', j);
|
||||
const lineEnd2 = nl2 === -1 ? n : nl2;
|
||||
if (text.slice(j, lineEnd2).trim() !== '') break;
|
||||
if (nl2 === -1) {
|
||||
j = n;
|
||||
break;
|
||||
}
|
||||
j = nl2 + 1;
|
||||
}
|
||||
boundaries.push({ offset: j, afterClosedFence: false });
|
||||
i = j;
|
||||
} else {
|
||||
i = afterNl;
|
||||
}
|
||||
}
|
||||
|
||||
return { boundaries, inFence };
|
||||
}
|
||||
|
||||
/**
|
||||
* Does cutting between `before` and `after` leave the rendered output unchanged?
|
||||
* This is the self-verifying safety check: it directly compares rendering the two
|
||||
* sides separately against rendering them joined, so constructs that span the cut
|
||||
* (loose lists, setext headings, lazy blockquote continuations, tables) are caught
|
||||
* with no hand-coded grammar rules.
|
||||
*
|
||||
* Renderer non-determinism (e.g. mermaid ids seeded with Date.now()) can only make
|
||||
* this return a false negative, never a false positive — so the bias is always
|
||||
* toward under-finalizing, which is the safe direction.
|
||||
*/
|
||||
function cutIsRenderSafe(before, after, render) {
|
||||
return render(before) + render(after) === render(before + after);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return how many leading characters of `text` can be safely finalized, scanning
|
||||
* forward from `committedLen` (the amount already finalized).
|
||||
*
|
||||
* Guarantees `render(text.slice(0, n)) + render(text.slice(n)) === render(text)`,
|
||||
* and `committedLen <= n <= text.length`.
|
||||
*
|
||||
* @param {string} text Full markdown accumulated so far.
|
||||
* @param {(src:string)=>string} render Canonical markdown renderer.
|
||||
* @param {number} [committedLen=0] Characters already finalized (always a prior boundary).
|
||||
* @returns {number}
|
||||
*/
|
||||
export function splitFinalized(text, render, committedLen = 0) {
|
||||
const { boundaries } = findBoundaries(text, committedLen);
|
||||
|
||||
let best = committedLen;
|
||||
let segStart = committedLen;
|
||||
|
||||
for (let k = 0; k < boundaries.length; k++) {
|
||||
const { offset, afterClosedFence } = boundaries[k];
|
||||
|
||||
if (afterClosedFence) {
|
||||
// A completed code block — always safe to freeze through here.
|
||||
best = offset;
|
||||
} else {
|
||||
// A prose/list/table boundary. We need a following block to compare
|
||||
// against (the last block must stay live, it can still grow), and the cut
|
||||
// must be render-equivalent locally.
|
||||
const nextOffset = k + 1 < boundaries.length ? boundaries[k + 1].offset : text.length;
|
||||
const before = text.slice(segStart, offset);
|
||||
const after = text.slice(offset, nextOffset);
|
||||
if (after.trim() !== '' && cutIsRenderSafe(before, after, render)) {
|
||||
best = offset;
|
||||
}
|
||||
}
|
||||
segStart = offset;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* If `text` begins with a fenced-code opener whose fence never closes, describe it
|
||||
* so the renderer can stream the code in append-mode instead of re-rendering it.
|
||||
* Returns `{ lang, contentStart }` (contentStart = offset of the first code char),
|
||||
* or null when `text` does not start with a still-open fence.
|
||||
*
|
||||
* The opener line must be complete (terminated by a newline) so the info string /
|
||||
* language is known before append-mode begins.
|
||||
*/
|
||||
export function describeOpenFence(text) {
|
||||
const open = text.match(/^( {0,3})(`{3,}|~{3,})([^\n]*)\n/);
|
||||
if (!open) return null;
|
||||
const marker = open[2];
|
||||
const contentStart = open[0].length;
|
||||
|
||||
for (let i = contentStart; i < text.length; ) {
|
||||
const nl = text.indexOf('\n', i);
|
||||
const line = text.slice(i, nl === -1 ? text.length : nl);
|
||||
const close = line.match(/^ {0,3}(`{3,}|~{3,})\s*$/);
|
||||
if (close && close[1][0] === marker[0] && close[1].length >= marker.length) {
|
||||
return null; // the fence closes — let the normal finalize path handle it
|
||||
}
|
||||
if (nl === -1) break;
|
||||
i = nl + 1;
|
||||
}
|
||||
|
||||
const lang = (open[3] || '').trim().split(/\s+/)[0] || '';
|
||||
return { lang, contentStart };
|
||||
}
|
||||
Reference in New Issue
Block a user