const IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\(([^)]+?)(?:\s+=\d+x(?:\d+|\*))?\)/g;
const MARKDOWN_ESCAPE_REGEX = /([*_\\])/g;
export function escapeHtml(text: string): string {
return text
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/\"/g, """)
.replace(/'/g, "'");
}
type InlineSegment = {
text: string;
styles: {
bold: boolean;
italic: boolean;
underline: boolean;
};
};
function restoreEscapes(text: string): string {
return text.replace(/\uE000/g, "\\");
}
function findItalicClose(
text: string,
start: number,
marker: "*" | "_",
): number {
let j = start;
while (j < text.length) {
const ch = text[j];
if (ch === "\uE000") {
j += 2;
continue;
}
if ((ch === "*" || ch === "_") && text[j + 1] === ch) {
const close = text.indexOf(ch + ch, j + 2);
if (close === -1) {
return -1;
}
j = close + 2;
continue;
}
if (ch === marker) {
return j;
}
j += 1;
}
return -1;
}
function parseInlineSegments(
normalized: string,
outer: { bold: boolean; italic: boolean; underline: boolean },
): InlineSegment[] {
const result: InlineSegment[] = [];
let buffer = "";
const pushPlain = () => {
if (!buffer) return;
result.push({ text: restoreEscapes(buffer), styles: { ...outer } });
buffer = "";
};
const wrap = (
inner: string,
add: Partial<{ bold: boolean; italic: boolean; underline: boolean }>,
) => {
pushPlain();
result.push(...parseInlineSegments(inner, { ...outer, ...add }));
};
let i = 0;
while (i < normalized.length) {
if (normalized.startsWith("***", i)) {
const end = normalized.indexOf("***", i + 3);
if (end !== -1) {
wrap(normalized.slice(i + 3, end), { bold: true, italic: true });
i = end + 3;
continue;
}
}
if (normalized.startsWith("___", i)) {
const end = normalized.indexOf("___", i + 3);
if (end !== -1) {
wrap(normalized.slice(i + 3, end), { bold: true, italic: true });
i = end + 3;
continue;
}
}
if (normalized.startsWith("**", i)) {
const end = normalized.indexOf("**", i + 2);
if (end !== -1) {
wrap(normalized.slice(i + 2, end), { bold: true });
i = end + 2;
continue;
}
}
if (normalized.startsWith("__", i)) {
const end = normalized.indexOf("__", i + 2);
if (end !== -1) {
wrap(normalized.slice(i + 2, end), { bold: true });
i = end + 2;
continue;
}
}
if (normalized.startsWith("", i)) {
const end = normalized.indexOf("", i + 3);
if (end !== -1) {
wrap(normalized.slice(i + 3, end), { underline: true });
i = end + 4;
continue;
}
}
if (normalized[i] === "*" || normalized[i] === "_") {
const marker = normalized[i] as "*" | "_";
const end = findItalicClose(normalized, i + 1, marker);
if (end !== -1) {
wrap(normalized.slice(i + 1, end), { italic: true });
i = end + 1;
continue;
}
}
buffer += normalized[i];
i += 1;
}
pushPlain();
return result;
}
function parseInlineMarkdown(text: string): InlineSegment[] {
if (!text) {
return [];
}
const normalized = text.replace(/\\([*_`~])/g, "\uE000$1");
return parseInlineSegments(normalized, {
bold: false,
italic: false,
underline: false,
});
}
function inlineToHtml(inline: InlineSegment[]): string {
return inline
.map(({ text, styles }) => {
let html = escapeHtml(text);
if (styles.bold) {
html = `${html}`;
}
if (styles.italic) {
html = `${html}`;
}
if (styles.underline) {
html = `${html}`;
}
return html;
})
.join("");
}
export function markdownToHtml(markdown: string): string {
if (!markdown) {
return "";
}
const lines = markdown.split(/\n/);
const htmlLines = lines.map((line) => {
const inline = parseInlineMarkdown(line);
const html = inlineToHtml(inline);
if (!html) {
return html;
}
return html.replace(
IMAGE_MARKDOWN_REGEX,
(_match, alt = "", src = "") =>
`
`,
);
});
return htmlLines.join("
");
}
export function escapeMarkdownText(text: string): string {
return text.replace(MARKDOWN_ESCAPE_REGEX, "\\$1");
}
export function normalizePlainText(text: string): string {
return text.replace(/\s+/g, " ").trim().toLowerCase();
}
function fallbackHtmlToMarkdown(html: string): string {
if (!html) {
return "";
}
let result = html;
result = result.replace(/
]*>/gi, (match) => {
const src = match.match(/src="([^"]*)"/i)?.[1] ?? "";
const alt = match.match(/alt="([^"]*)"/i)?.[1] ?? "";
return ``;
});
result = result
.replace(/
/gi, "\n")
.replace(/<\/?(div|p)>/gi, "\n")
.replace(/(.*?)<\/strong>/gis, (_m, content) => `**${content}**`)
.replace(/<(em|i)>(.*?)<\/(em|i)>/gis, (_m, _tag, content) => `_${content}_`)
.replace(/]*>/gi, "")
.replace(/<\/span>/gi, "")
.replace(/(.*?)<\/u>/gis, (_m, content) => `${content}`);
result = result.replace(/<\/?[^>]+>/g, "");
const markdown = result
.split("\n")
.map((line) => line.trimEnd())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
return cleanupEscapedFormatting(markdown);
}
export function htmlToMarkdown(html: string): string {
if (typeof document === "undefined") {
return fallbackHtmlToMarkdown(html);
}
const temp = document.createElement("div");
temp.innerHTML = html;
const traverse = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? "";
return escapeMarkdownText(text);
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return "";
}
const element = node as HTMLElement;
const children = Array.from(element.childNodes)
.map(traverse)
.join("");
switch (element.tagName.toLowerCase()) {
case "strong":
case "b":
return children ? `**${children}**` : children;
case "em":
case "i":
return children ? `*${children}*` : children;
case "u":
return children ? `${children}` : children;
case "br":
return "\n";
case "div":
case "p":
return children + "\n";
case "img": {
const src = element.getAttribute("src") ?? "";
const alt = element.getAttribute("alt") ?? "";
return ``;
}
default:
return children;
}
};
const markdown = Array.from(temp.childNodes).map(traverse).join("");
return cleanupEscapedFormatting(markdown).replace(/\n{3,}/g, "\n\n").trim();
}
function cleanupEscapedFormatting(markdown: string): string {
return markdown.replace(/(\\+)([*_]+)/g, (_match, slashes, markers) => {
if (markers.length === 0) {
return slashes + markers;
}
const shouldClean =
markers.length === 3 ||
markers.length === 2 ||
markers.length === 1;
if (!shouldClean) {
return slashes + markers;
}
const hasPrintable = slashes.length % 2 === 0;
return hasPrintable ? markers : slashes + markers;
});
}
export const __markdownStringUtils = {
cleanupEscapedFormatting,
};