114 lines
3.7 KiB
TypeScript
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;
|
|
}
|