import { marked } from "marked"; // Расширение для спойлеров 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]) { return renderer[token.type](token); } // 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) { return renderer.link(token); } // 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) { return renderer.spoiler(token); } return `${ token.text || "" }`; } return token.text || ""; }) .join(""); } // Кастомный renderer для внешних ссылок и чекбоксов const renderer: any = { link(token: any) { const href = token.href; const title = token.title; // Правильно обрабатываем вложенные токены для форматирования внутри ссылок let text = ""; if (token.tokens && token.tokens.length > 0) { 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}`; }, // Кастомный renderer для элементов списка с чекбоксами listitem(token: any) { const task = token.task; const checked = token.checked; // Правильно обрабатываем вложенные токены для форматирования let content = ""; if (token.tokens && token.tokens.length > 0) { // Рендерим вложенные токены используя наш renderer content = renderTokens(token.tokens, this); } else if (token.text) { // Если токенов нет, используем текст (для обратной совместимости) content = token.text; } if (task) { const checkbox = ``; return `
  • ${checkbox} ${content}
  • \n`; } return `
  • ${content}
  • \n`; }, }; // Настройка marked marked.use({ extensions: [spoilerExtension], gfm: true, breaks: true, renderer, }); export const parseMarkdown = (text: string): string => { return marked.parse(text) 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'); };