Files
Alexey Pavlov 20b67f11e0 feat: TOC оглавление + SVG-обложки-фоллбеки + /archive
TOC:
- renderMarkdownWithToc: парсит h2/h3, генерит транслит-якоря для кириллицы, возвращает {html, toc}
- TableOfContents компонент: sticky на десктопе, раскрывающийся блок на мобиле
- IntersectionObserver-free подсветка активной секции через scroll listener
- Двухколоночный layout статьи на lg+: 240px TOC + контент

SVG-обложки:
- ArticleCoverSVG: процедурно сгенерированная композиция (curve/circle/arc/rect) по seed = id статьи
- 6 палитр на выбор (emerald/teal/yellow/blue/purple/orange), seed детерминированный
- Используется в ArticleCard как fallback когда cover_url пусто
- На странице статьи тоже SVG если обложки нет
- Тег статьи отображается лейблом в углу

Архив:
- /archive: все статьи сгруппированы по месяцам, компактный список
- В Header добавлен пункт Архив (desktop+mobile)
- В Footer ссылки на Архив, Заметки, О проекте
- В sitemap.xml включён /archive
2026-05-31 10:54:34 +03:00

79 lines
2.3 KiB
JavaScript
Raw Permalink 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';
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 `<h${depth} id="${unique}">${innerHtml}</h${depth}>\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' });
}