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
This commit is contained in:
Alexey Pavlov
2026-05-31 10:54:34 +03:00
parent af4223bd0c
commit 20b67f11e0
9 changed files with 426 additions and 57 deletions
+63 -1
View File
@@ -5,8 +5,70 @@ marked.setOptions({
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 marked.parse(md || '');
return renderMarkdownWithToc(md).html;
}
export function formatDate(iso) {