253 lines
8.1 KiB
TypeScript
253 lines
8.1 KiB
TypeScript
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 `<span class="spoiler" title="Нажмите, чтобы показать">${token.text}</span>`;
|
||
},
|
||
};
|
||
|
||
// Функция для рендеринга вложенных токенов
|
||
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 `<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) {
|
||
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 `<a href="${href}"${title}>${text}</a>`;
|
||
}
|
||
if (token.type === "spoiler") {
|
||
// Для спойлеров используем кастомный renderer если доступен
|
||
if (renderer.spoiler) {
|
||
return renderer.spoiler(token);
|
||
}
|
||
return `<span class="spoiler" title="Нажмите, чтобы показать">${
|
||
token.text || ""
|
||
}</span>`;
|
||
}
|
||
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 `<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>`;
|
||
},
|
||
// Кастомный 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 = `<input type="checkbox" ${checked ? "checked" : ""} />`;
|
||
return `<li class="task-list-item">${checkbox} ${content}</li>\n`;
|
||
}
|
||
return `<li>${content}</li>\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 = `<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>');
|
||
};
|