import { marked } from "marked";
import hljs from "highlight.js";
// Импортируем стили
import "highlight.js/styles/github-dark.css";
import "highlight.js/styles/github.css";
// Расширение для спойлеров
const spoilerExtension = {
name: "spoiler",
level: "inline" as const,
start(src: string) {
return src.match(/\|\|/)?.index;
},
tokenizer(src: string) {
const rule = /^\|\|(.*?)\|\|/;
const match = rule.exec(src);
if (match) {
return {
type: "spoiler",
raw: match[0],
text: match[1].trim(),
};
}
},
renderer(token: any) {
return `${token.text}`;
},
};
// Функция для рендеринга вложенных токенов
function renderTokens(tokens: any[], renderer: any): string {
return tokens
.map((token) => {
// Используем кастомный renderer если он есть и является функцией
if (renderer[token.type] && typeof renderer[token.type] === 'function') {
try {
return renderer[token.type](token);
} catch (e) {
// Если метод не может обработать токен, используем fallback
}
}
// Fallback для встроенных типов токенов
if (token.type === "text") {
return token.text || "";
}
if (token.type === "strong") {
return `${renderTokens(token.tokens || [], renderer)}`;
}
if (token.type === "em") {
return `${renderTokens(token.tokens || [], renderer)}`;
}
if (token.type === "codespan") {
return `${token.text || ""}`;
}
if (token.type === "del") {
return `${renderTokens(token.tokens || [], renderer)}`;
}
if (token.type === "link") {
// Для ссылок используем кастомный renderer если доступен
if (renderer.link && typeof renderer.link === 'function') {
try {
return renderer.link(token);
} catch (e) {
// Если метод не может обработать токен, используем fallback
}
}
// Fallback для встроенных ссылок
const href = token.href || "";
const title = token.title ? ` title="${token.title}"` : "";
const text =
token.tokens && token.tokens.length > 0
? renderTokens(token.tokens, renderer)
: token.text || "";
return `${text}`;
}
if (token.type === "spoiler") {
// Для спойлеров используем кастомный renderer если доступен
if (renderer.spoiler && typeof renderer.spoiler === 'function') {
try {
return renderer.spoiler(token);
} catch (e) {
// Если метод не может обработать токен, используем fallback
}
}
return `${
token.text || ""
}`;
}
return token.text || "";
})
.join("");
}
// Функция для экранирования HTML
const escapeHtml = (text: string): string => {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
};
// Маппинг алиасов языков
const languageAliases: Record = {
js: "javascript",
ts: "typescript",
py: "python",
sh: "bash",
shell: "bash",
zsh: "bash",
yml: "yaml",
md: "markdown",
html: "markup",
xml: "markup",
svg: "markup",
mathml: "markup",
ssml: "markup",
atom: "markup",
rss: "markup",
};
// Функция для нормализации названия языка
const normalizeLanguage = (lang: string): string => {
const normalized = lang.toLowerCase().trim();
// Проверяем алиасы
return languageAliases[normalized] || normalized;
};
// Функция для подсветки синтаксиса кода
const highlightCode = (code: string, lang?: string): string => {
if (!lang) {
// Если язык не указан, просто экранируем HTML
return escapeHtml(code);
}
// Нормализуем название языка
const normalizedLang = normalizeLanguage(lang);
try {
// Используем highlight.js для подсветки синтаксиса
const highlighted = hljs.highlight(code, { language: normalizedLang });
return highlighted.value;
} catch (e: any) {
// Если произошла ошибка, пробуем автоопределение
try {
const autoHighlighted = hljs.highlightAuto(code);
return autoHighlighted.value;
} catch (autoError: any) {
// Если автоопределение тоже не сработало, логируем и экранируем
console.warn(
"Highlight.js error:",
e?.message || e,
"Language:",
normalizedLang
);
return escapeHtml(code);
}
}
};
// Кастомный renderer для внешних ссылок, чекбоксов и блоков кода
const createRenderer = (isReadOnly: boolean = false): any => {
// Создаем новый renderer, расширяя стандартный
const renderer = new marked.Renderer();
// Переопределяем метод link для обработки внешних ссылок
renderer.link = function(token: any) {
const href = token.href;
const title = token.title;
// Используем стандартный метод для рендеринга текста ссылки
let text = "";
if (token.tokens && token.tokens.length > 0) {
// Используем стандартные методы marked для рендеринга вложенных токенов
text = renderTokens(token.tokens, this);
} else if (token.text) {
text = token.text;
}
try {
const url = new URL(href, window.location.href);
const isExternal = url.origin !== window.location.origin;
if (isExternal) {
return `${text}`;
}
} catch {}
return `${text}`;
};
// Переопределяем метод listitem для обработки чекбоксов
const originalListitem = renderer.listitem.bind(renderer);
renderer.listitem = function(token: any) {
const task = token.task;
const checked = token.checked;
// Правильно обрабатываем вложенные токены для форматирования
let content = "";
if (token.tokens && token.tokens.length > 0) {
// Используем стандартные методы marked для рендеринга вложенных токенов
content = renderTokens(token.tokens, this);
} else if (token.text) {
// Если токенов нет, используем текст (для обратной совместимости)
content = token.text;
}
if (task) {
const disabledAttr = isReadOnly ? " disabled" : "";
const checkbox = ``;
return `
${checkbox} ${content}
\n`;
}
// Для обычных элементов списка используем стандартный метод
return originalListitem(token);
};
// Переопределяем метод code для подсветки синтаксиса
renderer.code = function(token: any) {
const code = token.text || "";
// В marked токен блока кода имеет поле lang
const lang = (token.lang || token.language || "").trim();
const highlightedCode = highlightCode(code, lang);
// Если язык указан, добавляем класс для стилизации (highlight.js использует класс hljs)
const langClass = lang ? `language-${lang}` : "";
const langLabel = lang ? `${lang}` : "";
return `
${langLabel}${highlightedCode}
`;
};
return renderer;
};
// Настройка marked (базовая конфигурация)
marked.use({
extensions: [spoilerExtension],
gfm: true,
breaks: true,
});
export const parseMarkdown = (text: string, isReadOnly: boolean = false): string => {
const renderer = createRenderer(isReadOnly);
// Используем marked.parse с renderer
const result = marked.parse(text, { renderer });
return result as string;
};
// Функция для извлечения тегов из текста
export const extractTags = (content: string): string[] => {
const tagRegex = /#([а-яё\w]+)/gi;
const tags: string[] = [];
let match;
while ((match = tagRegex.exec(content)) !== null) {
const matchIndex = match.index;
const beforeContext = content.substring(
Math.max(0, matchIndex - 100),
matchIndex
);
const afterContext = content.substring(
matchIndex + match[0].length,
Math.min(content.length, matchIndex + match[0].length + 100)
);
const lastOpenTag = beforeContext.lastIndexOf("<");
const lastCloseTag = beforeContext.lastIndexOf(">");
if (lastOpenTag > lastCloseTag) {
continue;
}
const lastQuote = Math.max(
beforeContext.lastIndexOf('"'),
beforeContext.lastIndexOf("'")
);
const lastEquals = beforeContext.lastIndexOf("=");
if (lastEquals > -1 && lastQuote > lastEquals) {
const nextQuote = Math.min(
afterContext.indexOf('"') !== -1 ? afterContext.indexOf('"') : Infinity,
afterContext.indexOf("'") !== -1 ? afterContext.indexOf("'") : Infinity
);
if (nextQuote !== Infinity) {
continue;
}
}
const tag = match[1];
// Проверяем, есть ли уже тег с таким же именем (регистронезависимо)
if (!tags.some((t) => t.toLowerCase() === tag.toLowerCase())) {
tags.push(tag);
}
}
return tags;
};
// Функция для преобразования тегов в кликабельные элементы
export const makeTagsClickable = (content: string): string => {
const tagRegex = /#([а-яё\w]+)/gi;
const matches: Array<{ fullMatch: string; tag: string; index: number }> = [];
let match;
while ((match = tagRegex.exec(content)) !== null) {
matches.push({
fullMatch: match[0],
tag: match[1],
index: match.index,
});
}
let result = content;
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const beforeTag = result.substring(0, match.index);
const afterTag = result.substring(match.index + match.fullMatch.length);
const lastOpenTag = beforeTag.lastIndexOf("<");
const lastCloseTag = beforeTag.lastIndexOf(">");
if (lastOpenTag > lastCloseTag) {
continue;
}
const beforeContext = beforeTag.substring(Math.max(0, match.index - 100));
const lastQuote = Math.max(
beforeContext.lastIndexOf('"'),
beforeContext.lastIndexOf("'")
);
const lastEquals = beforeContext.lastIndexOf("=");
if (lastEquals > -1 && lastQuote > lastEquals) {
const afterContext = afterTag.substring(
0,
Math.min(100, afterTag.length)
);
const nextQuote = Math.min(
afterContext.indexOf('"') !== -1 ? afterContext.indexOf('"') : Infinity,
afterContext.indexOf("'") !== -1 ? afterContext.indexOf("'") : Infinity
);
if (nextQuote !== Infinity) {
continue;
}
}
const replacement = `${match.fullMatch}`;
result = beforeTag + replacement + afterTag;
}
return result;
};
// Функция для подсветки найденного текста
export const highlightSearchText = (content: string, query: string): string => {
if (!query.trim()) return content;
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
return content.replace(regex, '$1');
};