147 lines
5.5 KiB
TypeScript
147 lines
5.5 KiB
TypeScript
/** 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;
|
|
}
|