export function normalizeMarkdownHardBreaks(markdown: string): string {
const normalized = markdown.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
let inFence = false;
let fenceChar: "`" | "~" | null = null;
return lines
.map((line) => {
const fence = line.match(/^\s*(`{3,}|~{3,})/);
if (fence) {
const marker = fence[1][0] as "`" | "~";
if (!inFence) {
inFence = true;
fenceChar = marker;
} else if (marker === fenceChar) {
inFence = false;
fenceChar = null;
}
return line;
}
if (inFence) return line;
return line.endsWith("\\") ? line.slice(0, -1) : line;
})
.join("\n");
}
export function decodeCommonHtmlEntities(value: string): string {
return value.replace(
/&(amp|lt|gt|quot|apos|#39|nbsp);/g,
(match, entity: string) => {
switch (entity) {
case "amp":
return "&";
case "lt":
return "<";
case "gt":
return ">";
case "quot":
return '"';
case "apos":
case "#39":
return "'";
case "nbsp":
return " ";
default:
return match;
}
},
);
}
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
type InlineTokenStore = {
put: (html: string) => string;
restore: (html: string) => string;
};
function createInlineTokenStore(): InlineTokenStore {
const tokens: string[] = [];
return {
put: (html) => `\uE000${tokens.push(html) - 1}\uE001`,
restore: (html) =>
html.replace(/\uE000(\d+)\uE001/g, (match, index: string) => {
return tokens[Number(index)] ?? match;
}),
};
}
function count(value: string, char: string): number {
return [...value].filter((c) => c === char).length;
}
function trimBareUrl(rawUrl: string): { url: string; trailing: string } {
let url = rawUrl;
let trailing = "";
while (/[>.,!?;:]$/.test(url)) {
trailing = url.slice(-1) + trailing;
url = url.slice(0, -1);
}
for (const [open, close] of [
["(", ")"],
["[", "]"],
["{", "}"],
]) {
while (url.endsWith(close) && count(url, close) > count(url, open)) {
trailing = close + trailing;
url = url.slice(0, -1);
}
}
return { url, trailing };
}
function renderInlineLabel(label: string): string {
const store = createInlineTokenStore();
const text = label.replace(/`([^`]+)`/g, (_match, code: string) =>
store.put(`${escapeHtml(code)}`),
);
const escaped = escapeHtml(text)
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?:;])/g, "$1$2");
return store.restore(escaped);
}
function anchorHtml(url: string, label = url): string {
return `${renderInlineLabel(label)}`;
}
export function renderInlineMarkdown(markdown: string): string {
const store = createInlineTokenStore();
let text = markdown;
text = text.replace(/`([^`]+)`/g, (_match, code: string) =>
store.put(`${escapeHtml(code)}`),
);
text = text.replace(
/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g,
(_match, alt: string, url: string) =>
store.put(
`
`,
),
);
text = text.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
(_match, label: string, url: string) => store.put(anchorHtml(url, label)),
);
text = text.replace(/<((?:https?:\/\/)[^<>\s]+)>/g, (_match, url: string) =>
store.put(anchorHtml(url)),
);
text = text.replace(
/(^|[^\w"'=])(https?:\/\/[^\s<]+)/g,
(_match, prefix: string, rawUrl: string) => {
const { url, trailing } = trimBareUrl(rawUrl);
if (!url) return `${prefix}${rawUrl}`;
return `${prefix}${store.put(anchorHtml(url))}${trailing}`;
},
);
const escaped = escapeHtml(text)
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?:;])/g, "$1$2");
return store.restore(escaped);
}
export function extractMarkdownUrls(markdown: string): string[] {
const urls = new Set();
let text = decodeCommonHtmlEntities(normalizeMarkdownHardBreaks(markdown));
const add = (rawUrl: string) => {
const { url } = trimBareUrl(rawUrl);
if (url) urls.add(url);
};
text = text.replace(/`[^`]+`/g, " ");
text = text.replace(
/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g,
(_match, url: string) => {
add(url);
return " ";
},
);
text = text.replace(
/\[[^\]]+\]\((https?:\/\/[^\s)]+)\)/g,
(_match, url: string) => {
add(url);
return " ";
},
);
text = text.replace(/<((?:https?:\/\/)[^<>\s]+)>/g, (_match, url: string) => {
add(url);
return " ";
});
text = text.replace(
/(^|[^\w"'=])(https?:\/\/[^\s<]+)/g,
(_match, _prefix: string, url: string) => {
add(url);
return "";
},
);
return [...urls];
}
export function markdownPreviewSnippet(
markdown: string,
maxLength = 120,
): string {
return decodeCommonHtmlEntities(normalizeMarkdownHardBreaks(markdown))
.slice(0, maxLength)
.replace(/\n/g, " ");
}