export interface LinkContext {
mark: string;
park: (html: string) => string;
safeUrl: (url: string) => string;
}
const DOUBLE_QUOTE = """;
const SINGLE_QUOTE = "'";
const SPACE = /\s/;
interface ScanMaps {
bracket: Int32Array;
paren: Int32Array;
wordEnd: Int32Array;
}
function skipPlaceholder(text: string, index: number, mark: string, to: number): number {
const close = text.indexOf(mark, index + 1);
return close === -1 || close + 1 > to ? to : close + 1;
}
function buildScanMaps(text: string, mark: string): ScanMaps {
const n = text.length;
const bracket = new Int32Array(n).fill(-1);
const paren = new Int32Array(n).fill(-1);
const wordEnd = new Int32Array(n + 1);
const bracketStack: number[] = [];
for (let i = 0; i < n; ) {
const char = text[i];
if (char === mark) {
i = skipPlaceholder(text, i, mark, n);
continue;
}
if (char === "\\") {
i += 2;
continue;
}
if (char === "[") {
bracketStack.push(i);
i++;
continue;
}
if (char === "]") {
const open = bracketStack.pop();
if (open !== undefined) bracket[open] = i;
i++;
continue;
}
i++;
}
const parenStack: number[] = [];
for (let i = 0; i < n; ) {
const char = text[i];
if (char === "\\") {
i += 2;
continue;
}
if (char === "(") {
parenStack.push(i);
i++;
continue;
}
if (char === ")") {
const open = parenStack.pop();
if (open !== undefined) paren[open] = i;
i++;
continue;
}
i++;
}
wordEnd[n] = n;
for (let i = n - 1; i >= 0; i--) {
const code = text.charCodeAt(i);
if (code === 92 && i + 1 < n) wordEnd[i] = wordEnd[i + 2 > n ? n : i + 2];
else if (isSpace(code, text[i])) wordEnd[i] = i;
else wordEnd[i] = wordEnd[i + 1];
}
return { bracket, paren, wordEnd };
}
function isSpace(code: number, char: string): boolean {
if (code < 128) return code === 32 || (code >= 9 && code <= 13);
return SPACE.test(char);
}
interface Destination {
url: string;
title?: string;
end: number;
}
function readTitle(text: string, index: number, to: number): { value: string; end: number } | null {
for (const [open, close] of [
[DOUBLE_QUOTE, DOUBLE_QUOTE],
[SINGLE_QUOTE, SINGLE_QUOTE],
["(", ")"],
]) {
if (index + open.length > to || !text.startsWith(open, index)) continue;
const from = index + open.length;
const found = text.indexOf(close, from);
if (found === -1 || found + close.length > to) return null;
return { value: text.slice(from, found), end: found + close.length };
}
return null;
}
function unescapeDestination(text: string, from: number, to: number): string {
let url = "";
let i = from;
while (i < to) {
if (text[i] === "\\" && i + 1 < to) {
url += text[i + 1];
i += 2;
continue;
}
url += text[i];
i++;
}
return url;
}
function readDestination(
text: string,
parenIndex: number,
maps: ScanMaps,
to: number,
): Destination | null {
const close = maps.paren[parenIndex];
if (close === -1 || close >= to) return null;
const from = parenIndex + 1;
const stop = Math.min(maps.wordEnd[from], close);
const url = unescapeDestination(text, from, stop);
if (stop === close) return { url, end: close + 1 };
let after = stop;
while (after < to && (text[after] === " " || text[after] === "\t")) after++;
if (after < to && text[after] === ")") return { url, end: after + 1 };
const title = readTitle(text, after, to);
if (!title) return null;
let titleClose = title.end;
while (titleClose < to && (text[titleClose] === " " || text[titleClose] === "\t")) titleClose++;
if (titleClose >= to || text[titleClose] !== ")") return null;
return { url, title: title.value, end: titleClose + 1 };
}
function attribute(name: string, value: string | undefined): string {
return value === undefined ? "" : ` ${name}="${value}"`;
}
export function renderLinks(text: string, context: LinkContext, textOnly = false): string {
if (!text.includes("[")) return text;
return scanLinks(text, context, textOnly, buildScanMaps(text, context.mark), 0, text.length);
}
function scanLinks(
text: string,
context: LinkContext,
textOnly: boolean,
maps: ScanMaps,
from: number,
to: number,
): string {
let out = "";
let i = from;
while (i < to) {
const char = text[i];
if (char === context.mark) {
const end = skipPlaceholder(text, i, context.mark, to);
out += text.slice(i, end);
i = end;
continue;
}
if (char === "\\" && i + 1 < to) {
out += text.slice(i, i + 2);
i += 2;
continue;
}
const isImage = char === "!" && i + 1 < to && text[i + 1] === "[";
if (char !== "[" && !isImage) {
out += char;
i++;
continue;
}
const bracket = isImage ? i + 1 : i;
const close = maps.bracket[bracket];
if (close === -1 || close >= to || close + 1 >= to || text[close + 1] !== "(") {
out += char;
i++;
continue;
}
const destination = readDestination(text, close + 1, maps, to);
if (!destination) {
out += char;
i++;
continue;
}
const url = context.safeUrl(destination.url);
if (isImage) {
const alt = scanLinks(text, context, true, maps, bracket + 1, close);
const tag = `
`;
out += textOnly ? alt : context.park(tag);
} else if (textOnly) {
out += scanLinks(text, context, true, maps, bracket + 1, close);
} else {
const open = ``;
out +=
context.park(open) +
scanLinks(text, context, false, maps, bracket + 1, close) +
context.park("");
}
i = destination.end;
}
return out;
}