import { marked } from 'marked'; marked.setOptions({ gfm: true, breaks: false, }); /** * Slug для якорей — поддерживает кириллицу и латиницу. */ function slugifyHeading(text) { const map = { а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'yo',ж:'zh',з:'z',и:'i',й:'y', к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f', х:'h',ц:'c',ч:'ch',ш:'sh',щ:'sch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya', }; return String(text) .toLowerCase() .split('') .map(c => map[c] !== undefined ? map[c] : c) .join('') .replace(/<[^>]+>/g, '') .replace(/[^a-z0-9\s-]/g, '') .trim() .replace(/\s+/g, '-') .replace(/-+/g, '-') .substring(0, 80); } /** * Рендерит markdown и одновременно возвращает оглавление. * @returns {{ html: string, toc: Array<{level:number,id:string,text:string}> }} */ export function renderMarkdownWithToc(md) { if (!md) return { html: '', toc: [] }; const toc = []; const usedIds = new Set(); const renderer = new marked.Renderer(); renderer.heading = ({ tokens, depth, text }) => { // marked v13+: heading получает объект, text может быть в tokens let rawText = text; if (Array.isArray(tokens)) { rawText = tokens.map(t => t.raw || t.text || '').join(''); } rawText = String(rawText || '').trim(); let id = slugifyHeading(rawText); if (!id) id = `h-${toc.length}`; // уникализируем let unique = id; let i = 2; while (usedIds.has(unique)) unique = `${id}-${i++}`; usedIds.add(unique); // в TOC берём только h2 и h3 if (depth === 2 || depth === 3) { toc.push({ level: depth, id: unique, text: rawText }); } const innerHtml = marked.parseInline(rawText); return `${innerHtml}\n`; }; const html = marked.parse(md, { renderer }); return { html, toc }; } // Legacy — оставляю, чтобы не сломать другие места export function renderMarkdown(md) { return renderMarkdownWithToc(md).html; } export function formatDate(iso) { if (!iso) return ''; const d = new Date(iso); return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }); }