20b67f11e0
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
79 lines
2.3 KiB
JavaScript
79 lines
2.3 KiB
JavaScript
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' });
|
||
}
|