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'); };