/** A half-open span containing a complete Markdown link or image. */ export type MarkdownImageRange = { start: number; end: number }; type Parsed = { kind: "complete"; end: number } | { kind: "pending" } | { kind: "invalid" }; // This scanner recognizes syntax only; rendering and URL safety remain the // responsibility of the existing Markdown renderer and image splitter. function readDestination(text: string, start: number, allowWhitespace = false): Parsed { if (start >= text.length) return { kind: "pending" }; if (text[start] !== "(") return { kind: "invalid" }; let depth = 1; for (let i = start + 1; i < text.length; i += 1) { const char = text[i]; if (char === "\\") { if (i + 1 >= text.length) return { kind: "pending" }; i += 1; continue; } if (char === "\n" || char === "\r" || (!allowWhitespace && /\s/.test(char))) return { kind: "invalid" }; if (char === "(") depth += 1; if (char === ")" && --depth === 0) return { kind: "complete", end: i + 1 }; } return { kind: "pending" }; } function readImage(text: string, start: number, wrapped: boolean): Parsed { const imageStart = start + (wrapped ? 1 : 0); if (imageStart + 1 >= text.length) return { kind: "pending" }; if (text[imageStart] !== "!" || text[imageStart + 1] !== "[") return { kind: "invalid" }; let depth = 1; for (let i = imageStart + 2; i < text.length; i += 1) { const char = text[i]; if (char === "\n" || char === "\r") return { kind: "invalid" }; if (char === "\\") { if (i + 1 >= text.length) return { kind: "pending" }; i += 1; continue; } if (char === "[") depth += 1; if (char !== "]" || --depth !== 0) continue; const inner = readDestination(text, i + 1); if (inner.kind !== "complete" || !wrapped) return inner; if (inner.end >= text.length) return { kind: "pending" }; if (text[inner.end] !== "]") return { kind: "invalid" }; return readDestination(text, inner.end + 1); } return { kind: "pending" }; } function readLink(text: string, start: number): Parsed { let depth = 1; for (let i = start + 1; i < text.length; i += 1) { const char = text[i]; // An unfinished bracket on an earlier line must not hide later output. if (char === "\n" || char === "\r") return { kind: "invalid" }; if (char === "\\") { if (i + 1 >= text.length) return { kind: "pending" }; i += 1; continue; } if (char === "[") depth += 1; if (char !== "]" || --depth !== 0) continue; if (text[i + 1] === "[") { for (let j = i + 2; j < text.length; j += 1) { if (text[j] === "\n" || text[j] === "\r") return { kind: "invalid" }; if (text[j] === "\\") { if (j + 1 >= text.length) return { kind: "pending" }; j += 1; continue; } if (text[j] === "]") return { kind: "complete", end: j + 1 }; } return { kind: "pending" }; } return readDestination(text, i + 1, true); } return { kind: "pending" }; } /** Locate complete Markdown links/images and an incomplete trailing construct. * Code spans/fences and escaped markers remain normal text. */ export function scanMarkdownImageStreaming(text: string): { ranges: MarkdownImageRange[]; pendingStart: number | null; } { const ranges: MarkdownImageRange[] = []; let cursor = 0; while (cursor < text.length) { if (text[cursor] === "\\") { cursor += 2; continue; } if (text[cursor] === "`") { let end = cursor + 1; while (text[end] === "`") end += 1; const marker = text.slice(cursor, end); const close = text.indexOf(marker, end); cursor = close < 0 ? text.length : close + marker.length; continue; } if (cursor === 0 || text[cursor - 1] === "\n") { const fence = text.slice(cursor).match(/^ {0,3}(~{3,})[^\n]*(?:\n|$)/); if (fence) { const closing = new RegExp(`^ {0,3}~{${fence[1].length},}\\s*$`); let line = cursor + fence[0].length; while (line < text.length) { const next = text.indexOf("\n", line); const end = next < 0 ? text.length : next; if (closing.test(text.slice(line, end))) { line = next < 0 ? end : end + 1; break; } line = next < 0 ? end : end + 1; } cursor = line; continue; } } const char = text[cursor]; if (char !== "[" && char !== "!") { cursor += 1; continue; } const wrapped = char === "["; const parsed = wrapped && text[cursor + 1] !== "!" ? readLink(text, cursor) : readImage(text, cursor, wrapped); if (parsed.kind === "pending") return { ranges, pendingStart: cursor }; if (parsed.kind === "complete") { ranges.push({ start: cursor, end: parsed.end }); cursor = parsed.end; } else cursor += 1; } return { ranges, pendingStart: null }; } /** During active delivery, withhold only unfinished link/image syntax at the tail. */ export function stableStreamingMarkdown(text: string, streaming: boolean): string { if (!streaming) return text; const { pendingStart } = scanMarkdownImageStreaming(text); return pendingStart === null ? text : text.slice(0, pendingStart); } export function advancePastAtomicRanges(length: number, target: number, ranges: MarkdownImageRange[]) { let position = Math.min(length, target); // Overlapping table/link ranges can require more than one jump. for (let changed = true; changed;) { changed = false; for (const range of ranges) { if (position > range.start && position < range.end) { position = Math.min(length, range.end); changed = true; } } } return position; }