ai-chatbot/lib/canvas/markdown-image-segments.ts

114 lines
3.7 KiB
TypeScript

export type MarkdownImageSegment =
| { type: "text"; value: string }
| { type: "image"; alt?: string; src: string };
// Markdown destinations may include balanced parentheses and backslash escapes.
function readDestination(content: string, open: number) {
if (content[open] !== "(") return null;
let depth = 1;
for (let index = open + 1; index < content.length; index += 1) {
const character = content[index];
if (character === "\\") {
index += 1;
continue;
}
if (character === "(") depth += 1;
if (character === ")" && --depth === 0) {
const raw = content.slice(open + 1, index);
if (!/^https?:\/\//i.test(raw) || /\s/.test(raw)) return null;
return { url: raw.replace(/\\([()\\])/g, "$1"), end: index + 1 };
}
}
return null;
}
function readImage(content: string, start: number) {
if (!content.startsWith("![", start)) return null;
let depth = 1;
for (let index = start + 2; index < content.length; index += 1) {
const character = content[index];
if (character === "\\") {
index += 1;
continue;
}
if (character === "[") depth += 1;
if (character !== "]" || --depth !== 0) continue;
const destination = readDestination(content, index + 1);
if (!destination) return null;
return {
alt: content.slice(start + 2, index).replace(/\\([\[\]\\])/g, "$1"),
src: destination.url,
end: destination.end,
};
}
return null;
}
export function splitMarkdownImages(content: string): MarkdownImageSegment[] {
const segments: MarkdownImageSegment[] = [];
let textStart = 0;
let cursor = 0;
while (cursor < content.length) {
// Leave Markdown examples in code spans and fences untouched.
if (content[cursor] === "`") {
let endOfRun = cursor + 1;
while (content[endOfRun] === "`") endOfRun += 1;
const marker = content.slice(cursor, endOfRun);
const close = content.indexOf(marker, endOfRun);
cursor = close < 0 ? content.length : close + marker.length;
continue;
}
const lineStart = cursor === 0 || content[cursor - 1] === "\n";
if (lineStart) {
const fence = content.slice(cursor).match(/^ {0,3}(~{3,})[^\n]*(?:\n|$)/);
if (fence) {
const marker = fence[1];
const closing = new RegExp(`^ {0,3}~{${marker.length},}\\s*$`);
let line = cursor + fence[0].length;
while (line < content.length) {
const next = content.indexOf("\n", line);
const end = next < 0 ? content.length : next;
if (closing.test(content.slice(line, end))) {
line = next < 0 ? end : end + 1;
break;
}
line = next < 0 ? end : end + 1;
}
cursor = line;
continue;
}
}
if (content[cursor] === "\\") {
cursor += 2;
continue;
}
const wrapped = content.startsWith("[![", cursor);
const image = readImage(content, cursor + (wrapped ? 1 : 0));
if (!image) {
cursor += 1;
continue;
}
let end = image.end;
if (wrapped) {
const outer = content[end] === "]" ? readDestination(content, end + 1) : null;
// Preserve incomplete, unsafe and different-target links as Markdown so
// Streamdown applies its normal link sanitization and click behavior.
if (!outer || outer.url !== image.src) {
cursor = outer ? outer.end : end;
continue;
}
end = outer.end;
}
if (cursor > textStart) {
segments.push({ type: "text", value: content.slice(textStart, cursor) });
}
segments.push({ type: "image", alt: image.alt, src: image.src });
cursor = end;
textStart = end;
}
if (textStart < content.length) {
segments.push({ type: "text", value: content.slice(textStart) });
}
return segments;
}