noteJS-react/src/utils/markdown.ts

362 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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