@@ -91,6 +92,11 @@ export default function Header() {
onClick={() => setOpen(false)}
className="text-2xl font-semibold ink py-3 border-b-soft"
>Статьи
+
setOpen(false)}
+ className="text-2xl font-semibold ink py-3 border-b-soft"
+ >Архив
setOpen(false)}
diff --git a/components/TableOfContents.js b/components/TableOfContents.js
new file mode 100644
index 0000000..c28224f
--- /dev/null
+++ b/components/TableOfContents.js
@@ -0,0 +1,99 @@
+'use client';
+import { useEffect, useState } from 'react';
+import { List, ChevronDown } from 'lucide-react';
+
+export default function TableOfContents({ items }) {
+ const [activeId, setActiveId] = useState(null);
+ const [mobileOpen, setMobileOpen] = useState(false);
+
+ // следим за тем, какой заголовок сейчас в viewport
+ useEffect(() => {
+ if (!items?.length) return;
+ const headings = items
+ .map(i => document.getElementById(i.id))
+ .filter(Boolean);
+ if (!headings.length) return;
+
+ const onScroll = () => {
+ // ближайший к верху, но не выше offset
+ const offset = 120;
+ let current = headings[0]?.id || null;
+ for (const h of headings) {
+ const top = h.getBoundingClientRect().top;
+ if (top <= offset) current = h.id;
+ else break;
+ }
+ setActiveId(current);
+ };
+ onScroll();
+ window.addEventListener('scroll', onScroll, { passive: true });
+ return () => window.removeEventListener('scroll', onScroll);
+ }, [items]);
+
+ function scrollTo(e, id) {
+ e.preventDefault();
+ const el = document.getElementById(id);
+ if (el) {
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ setMobileOpen(false);
+ }
+ }
+
+ if (!items || items.length < 2) return null;
+
+ return (
+ <>
+ {/* Desktop: sticky слева, видна на lg+ */}
+
+
+ {/* Mobile: раскрывающийся блок над текстом */}
+
+ >
+ );
+}
diff --git a/lib/markdown.js b/lib/markdown.js
index 7689cbf..5ec73b7 100644
--- a/lib/markdown.js
+++ b/lib/markdown.js
@@ -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 `
${innerHtml}\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) {