362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
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 `<span class="spoiler" title="Нажмите, чтобы показать">${token.text}</span>`;
|
||
},
|
||
};
|
||
|
||
// Функция для рендеринга вложенных токенов
|
||
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 `<strong>${renderTokens(token.tokens || [], renderer)}</strong>`;
|
||
}
|
||
if (token.type === "em") {
|
||
return `<em>${renderTokens(token.tokens || [], renderer)}</em>`;
|
||
}
|
||
if (token.type === "codespan") {
|
||
return `<code>${token.text || ""}</code>`;
|
||
}
|
||
if (token.type === "del") {
|
||
return `<del>${renderTokens(token.tokens || [], renderer)}</del>`;
|
||
}
|
||
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 `<a href="${href}"${title}>${text}</a>`;
|
||
}
|
||
if (token.type === "spoiler") {
|
||
// Для спойлеров используем кастомный renderer если доступен
|
||
if (renderer.spoiler && typeof renderer.spoiler === 'function') {
|
||
try {
|
||
return renderer.spoiler(token);
|
||
} catch (e) {
|
||
// Если метод не может обработать токен, используем fallback
|
||
}
|
||
}
|
||
return `<span class="spoiler" title="Нажмите, чтобы показать">${
|
||
token.text || ""
|
||
}</span>`;
|
||
}
|
||
return token.text || "";
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
// Функция для экранирования HTML
|
||
const escapeHtml = (text: string): string => {
|
||
const div = document.createElement("div");
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
};
|
||
|
||
// Маппинг алиасов языков
|
||
const languageAliases: Record<string, string> = {
|
||
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 `<a href="${href}" title="${
|
||
title || ""
|
||
}" target="_blank" rel="noopener noreferrer" class="external-link">${text}</a>`;
|
||
}
|
||
} catch {}
|
||
|
||
return `<a href="${href}"${title ? ` title="${title}"` : ""}>${text}</a>`;
|
||
};
|
||
|
||
// Переопределяем метод 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 = `<input type="checkbox" ${checked ? "checked" : ""}${disabledAttr} />`;
|
||
return `<li class="task-list-item">${checkbox} ${content}</li>\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 ? `<span class="code-language">${lang}</span>` : "";
|
||
|
||
return `<pre class="code-block">${langLabel}<code class="hljs ${langClass}">${highlightedCode}</code></pre>`;
|
||
};
|
||
|
||
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 = `<span class="tag-in-note" data-tag="${match.tag}">${match.fullMatch}</span>`;
|
||
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, '<mark class="search-highlight">$1</mark>');
|
||
};
|