@webnumseoagent/next 0.7.7 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/blog.tsx +114 -11
- package/dist/blog-toc.d.ts +3 -0
- package/dist/blog-toc.js +69 -0
- package/dist/blog.d.ts +13 -0
- package/dist/blog.js +93 -3
- package/package.json +1 -1
package/blog.tsx
CHANGED
|
@@ -14,13 +14,17 @@
|
|
|
14
14
|
|
|
15
15
|
import type { CSSProperties } from "react";
|
|
16
16
|
import { SeoBlogGrid } from "./blog-grid";
|
|
17
|
+
import { SeoBlogTocSpy } from "./blog-toc";
|
|
17
18
|
|
|
18
19
|
// Файловый ридер (node:fs) грузим лениво — только когда блог реально рендерится (на сборке).
|
|
19
20
|
const blogfs = () => import("./blog-fs");
|
|
20
21
|
|
|
21
22
|
// Тема блога: цвета/скругления как ВАЛИДНЫЕ CSS-значения → пакет кладёт их в свои --sa-*. Приходит из
|
|
22
23
|
// .blogfs.json (платформа пишет бренд-цвет владельца в accent + опц. токены оформления сайта).
|
|
23
|
-
|
|
24
|
+
// toc — оглавление статьи: enabled (вкл/выкл), variant (вид), motion (анимация), top (офсет sticky/якоря),
|
|
25
|
+
// css (маленький scoped-CSS под .sa-toc от Designer-агента — индивидуальный вид/анимация под стиль сайта).
|
|
26
|
+
export interface SeoBlogTocTheme { enabled?: boolean; variant?: string; motion?: boolean; top?: string; css?: string }
|
|
27
|
+
export interface SeoBlogTheme { accent?: string; border?: string; muted?: string; surface?: string; thBg?: string; radius?: string; toc?: SeoBlogTocTheme }
|
|
24
28
|
export interface SeoBlogSummary { slug: string; title: string; excerpt: string; cover: string | null; date: string | null }
|
|
25
29
|
export interface SeoBlogPostFull extends SeoBlogSummary { bodyHtml: string; theme?: SeoBlogTheme }
|
|
26
30
|
|
|
@@ -57,8 +61,8 @@ export async function seoBlogStaticParams(opts?: { localeParam?: string }): Prom
|
|
|
57
61
|
const BODY_CSS = `.seoagent-blog-body{color:inherit}
|
|
58
62
|
.seoagent-blog-body img{max-width:100%;height:auto;border-radius:var(--sa-radius,8px);margin:1rem 0}
|
|
59
63
|
.seoagent-blog-body table{width:100%;border-collapse:collapse;margin:1rem 0;font-size:.95em}
|
|
60
|
-
.seoagent-blog-body th,.seoagent-blog-body td{border:1px solid var(--sa-border
|
|
61
|
-
.seoagent-blog-body th{background:var(--sa-th-bg
|
|
64
|
+
.seoagent-blog-body th,.seoagent-blog-body td{border:1px solid var(--sa-border,color-mix(in srgb,currentColor 16%,transparent));padding:.5rem .75rem;text-align:left}
|
|
65
|
+
.seoagent-blog-body th{background:var(--sa-th-bg,color-mix(in srgb,currentColor 8%,transparent));color:inherit;font-weight:700}
|
|
62
66
|
.seoagent-blog-body h2{margin:1.75rem 0 .75rem;font-size:1.5rem;line-height:1.3}
|
|
63
67
|
.seoagent-blog-body h3{margin:1.4rem 0 .6rem;font-size:1.2rem}
|
|
64
68
|
.seoagent-blog-body p{margin:0 0 1rem}
|
|
@@ -73,6 +77,84 @@ const BODY_CSS = `.seoagent-blog-body{color:inherit}
|
|
|
73
77
|
.seoagent-blog-body figcaption{color:var(--sa-muted,#94a3b8);font-size:.85em;text-align:center;margin-top:.4rem}
|
|
74
78
|
.seoagent-blog-body blockquote{margin:1rem 0;padding:.5rem 1rem;border-left:3px solid var(--sa-border,#e5e7eb);color:var(--sa-muted,#475569)}`;
|
|
75
79
|
|
|
80
|
+
// ── Оглавление статьи (TOC) ────────────────────────────────────────────────────────────────────
|
|
81
|
+
// Санитайзер РЕЖЕТ id у заголовков → якоря нельзя протащить из контента. Поэтому адаптер САМ строит
|
|
82
|
+
// оглавление и проставляет id при рендере (детерминированно, server-side, без сети и без зависимостей).
|
|
83
|
+
const TOC_LABEL: Record<string, string> = { ru: "Содержание", uz: "Mundarija", en: "Contents", ar: "المحتويات", tr: "İçindekiler" };
|
|
84
|
+
const TOC_MIN = 3; // меньше 3 заголовков — оглавление не показываем (не нужно)
|
|
85
|
+
|
|
86
|
+
// Транслит кириллицы → латиница для стабильных слагов-якорей (uzbek-latin уже латиница).
|
|
87
|
+
const TRANSLIT: Record<string, string> = {
|
|
88
|
+
а:"a",б:"b",в:"v",г:"g",д:"d",е:"e",ё:"e",ж:"zh",з:"z",и:"i",й:"y",к:"k",л:"l",м:"m",н:"n",о:"o",п:"p",
|
|
89
|
+
р:"r",с:"s",т:"t",у:"u",ф:"f",х:"h",ц:"ts",ч:"ch",ш:"sh",щ:"sch",ъ:"",ы:"y",ь:"",э:"e",ю:"yu",я:"ya",
|
|
90
|
+
ў:"o",қ:"q",ғ:"g",ҳ:"h",
|
|
91
|
+
};
|
|
92
|
+
function slugify(text: string): string {
|
|
93
|
+
const s = text.toLowerCase().split("").map((ch) => TRANSLIT[ch] ?? ch).join("")
|
|
94
|
+
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
95
|
+
return s || "section";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SeoTocItem { id: string; text: string; level: 2 | 3 }
|
|
99
|
+
// Извлекает h2/h3, впрыскивает уникальные id в открывающие теги, возвращает {html, items}. Fail-safe.
|
|
100
|
+
function buildToc(html: string): { html: string; items: SeoTocItem[] } {
|
|
101
|
+
const items: SeoTocItem[] = [];
|
|
102
|
+
const used = new Set<string>();
|
|
103
|
+
try {
|
|
104
|
+
const out = html.replace(/<(h2|h3)(\s[^>]*)?>([\s\S]*?)<\/\1>/gi, (full, tag: string, attrs: string, inner: string) => {
|
|
105
|
+
const text = inner.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
|
106
|
+
if (!text) return full;
|
|
107
|
+
let id = slugify(text); const base = id; let n = 2;
|
|
108
|
+
while (used.has(id)) { id = `${base}-${n}`; n++; }
|
|
109
|
+
used.add(id);
|
|
110
|
+
items.push({ id, text, level: tag.toLowerCase() === "h3" ? 3 : 2 });
|
|
111
|
+
return `<${tag}${attrs || ""} id="${id}">${inner}</${tag}>`;
|
|
112
|
+
});
|
|
113
|
+
return { html: out, items };
|
|
114
|
+
} catch {
|
|
115
|
+
return { html, items: [] };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Валидатор персонального CSS оглавления (от Designer-агента). Только безопасные правила под .sa-toc:
|
|
120
|
+
// без выхода из <style>, без @import/expression/js-url. Иначе возвращаем пусто (fail-safe).
|
|
121
|
+
function safeTocCss(css?: string): string {
|
|
122
|
+
if (!css || typeof css !== "string") return "";
|
|
123
|
+
if (/<\/?style|@import|expression\s*\(|javascript:|<script/i.test(css)) return "";
|
|
124
|
+
return css.slice(0, 4000);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const TOC_CSS = `.sa-article-wrap{max-width:1080px;margin:0 auto;padding:var(--sa-blog-top,clamp(5rem,8vw,7rem)) 1.5rem 3rem}
|
|
128
|
+
.sa-article-wrap.has-toc{display:grid;gap:2.5rem;grid-template-columns:1fr}
|
|
129
|
+
@media(min-width:1024px){.sa-article-wrap.has-toc{grid-template-columns:250px minmax(0,760px);justify-content:center;align-items:start}}
|
|
130
|
+
.sa-article{min-width:0;line-height:1.7}
|
|
131
|
+
.sa-article-wrap:not(.has-toc) .sa-article{max-width:760px;margin:0 auto}
|
|
132
|
+
.sa-toc{order:-1;font-size:.9rem}
|
|
133
|
+
@media(min-width:1024px){.sa-toc{position:sticky;top:var(--sa-toc-top,88px);max-height:calc(100vh - 120px);overflow:auto}}
|
|
134
|
+
.sa-toc>summary{list-style:none;font-weight:700;font-size:.95rem;margin:0 0 .75rem;display:flex;align-items:center;justify-content:space-between;gap:.5rem;color:inherit;cursor:pointer}
|
|
135
|
+
.sa-toc>summary::-webkit-details-marker{display:none}
|
|
136
|
+
.sa-toc>summary .sa-toc-chev{transition:transform .2s ease;color:var(--sa-muted,#94a3b8)}
|
|
137
|
+
.sa-toc[open]>summary .sa-toc-chev{transform:rotate(90deg)}
|
|
138
|
+
@media(min-width:1024px){.sa-toc>summary{pointer-events:none}.sa-toc>summary .sa-toc-chev{display:none}}
|
|
139
|
+
.sa-toc-list{display:flex;flex-direction:column;gap:.1rem}
|
|
140
|
+
.sa-toc-list a{display:block;text-decoration:none;color:var(--sa-muted,#64748b);padding:.35rem .6rem;border-radius:var(--sa-radius,8px);line-height:1.4;transition:color .15s ease,background .15s ease,border-color .15s ease}
|
|
141
|
+
.sa-toc-list a[data-level="3"]{padding-left:1.4rem;font-size:.85em}
|
|
142
|
+
.sa-toc-list a:hover{color:inherit}
|
|
143
|
+
.sa-toc-list a.is-active{color:var(--sa-accent,#2563eb)}
|
|
144
|
+
.sa-toc[data-sa-toc="minimal"] .sa-toc-list{border-left:2px solid var(--sa-border,#e5e7eb);padding-left:.2rem}
|
|
145
|
+
.sa-toc[data-sa-toc="minimal"] .sa-toc-list a{border-left:2px solid transparent;margin-left:-.2rem;border-radius:0}
|
|
146
|
+
.sa-toc[data-sa-toc="minimal"] .sa-toc-list a.is-active{border-left-color:var(--sa-accent,#2563eb);font-weight:600}
|
|
147
|
+
.sa-toc[data-sa-toc="bordered"]{border:1px solid var(--sa-border,#e5e7eb);border-radius:var(--sa-radius,12px);padding:1rem;background:var(--sa-surface,transparent)}
|
|
148
|
+
.sa-toc[data-sa-toc="bordered"] .sa-toc-list a.is-active{background:var(--sa-th-bg,color-mix(in srgb,currentColor 8%,transparent))}
|
|
149
|
+
.sa-toc[data-sa-toc="pill"] .sa-toc-list a.is-active{background:var(--sa-accent,#2563eb);color:#fff}
|
|
150
|
+
.sa-toc[data-sa-toc="numbered"] .sa-toc-list{counter-reset:sa-toc}
|
|
151
|
+
.sa-toc[data-sa-toc="numbered"] .sa-toc-list a[data-level="2"]{counter-increment:sa-toc}
|
|
152
|
+
.sa-toc[data-sa-toc="numbered"] .sa-toc-list a[data-level="2"]::before{content:counter(sa-toc) ". ";color:var(--sa-muted,#94a3b8)}
|
|
153
|
+
.sa-toc[data-sa-toc="numbered"] .sa-toc-list a.is-active::before{color:var(--sa-accent,#2563eb)}
|
|
154
|
+
.seoagent-blog-body h2,.seoagent-blog-body h3{scroll-margin-top:var(--sa-toc-top,88px)}
|
|
155
|
+
@media(prefers-reduced-motion:no-preference){.sa-toc[data-motion="on"] .sa-toc-list{animation:sa-toc-in .45s ease both}}
|
|
156
|
+
@keyframes sa-toc-in{from{opacity:0;transform:translateX(-6px)}to{opacity:1;transform:none}}`;
|
|
157
|
+
|
|
76
158
|
// Тема → CSS-переменные пакета (--sa-*). Кладём на корень блока — они каскадируются вниз к
|
|
77
159
|
// ссылкам/карточкам/телу. Отдаём только заданные ключи (пустые не трогаем → останется fallback).
|
|
78
160
|
function themeStyle(theme?: SeoBlogTheme): Record<string, string> {
|
|
@@ -142,15 +224,36 @@ export async function SeoBlogIndex(props: { locale?: string; basePath?: string;
|
|
|
142
224
|
|
|
143
225
|
// Одна статья. Принимает пост (из seoBlogPost) — так страница может сделать notFound()/метаданные.
|
|
144
226
|
export function SeoBlogArticle({ post, locale }: { post: SeoBlogPostFull; locale?: string }) {
|
|
227
|
+
const toc = post.theme?.toc ?? {};
|
|
228
|
+
// Оглавление строим ВСЕГДА (нужны id-якоря в тексте), а показываем только если вкл. и ≥TOC_MIN заголовков.
|
|
229
|
+
const { html, items } = buildToc(post.bodyHtml);
|
|
230
|
+
const showToc = toc.enabled !== false && items.length >= TOC_MIN;
|
|
231
|
+
const variant = toc.variant || "minimal";
|
|
232
|
+
const motion = toc.motion === false ? "off" : "on";
|
|
233
|
+
const label = L(TOC_LABEL, locale ?? "", "Contents");
|
|
234
|
+
const rootStyle = { ...themeStyle(post.theme), ...(toc.top ? { ["--sa-toc-top"]: toc.top } : {}) } as CSSProperties;
|
|
145
235
|
return (
|
|
146
|
-
<
|
|
147
|
-
<style dangerouslySetInnerHTML={{ __html: BODY_CSS }} />
|
|
148
|
-
{
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
236
|
+
<div className={showToc ? "sa-article-wrap has-toc" : "sa-article-wrap"} style={rootStyle}>
|
|
237
|
+
<style dangerouslySetInnerHTML={{ __html: BODY_CSS + TOC_CSS + (showToc ? safeTocCss(toc.css) : "") }} />
|
|
238
|
+
{showToc ? (
|
|
239
|
+
<details className="sa-toc" data-sa-toc={variant} data-motion={motion} open>
|
|
240
|
+
<summary>{label}<span className="sa-toc-chev" aria-hidden="true">›</span></summary>
|
|
241
|
+
<nav className="sa-toc-list" aria-label={label}>
|
|
242
|
+
{items.map((it) => (
|
|
243
|
+
<a key={it.id} href={`#${it.id}`} data-toc-link={it.id} data-level={it.level}>{it.text}</a>
|
|
244
|
+
))}
|
|
245
|
+
</nav>
|
|
246
|
+
<SeoBlogTocSpy ids={items.map((it) => it.id)} />
|
|
247
|
+
</details>
|
|
248
|
+
) : null}
|
|
249
|
+
<article className="sa-article">
|
|
250
|
+
{post.cover ? <img src={post.cover} alt={post.title} style={{ width: "100%", aspectRatio: "3 / 2", objectFit: "cover", display: "block", borderRadius: "var(--sa-radius, 12px)", marginBottom: "1.5rem" }} /> : null}
|
|
251
|
+
<h1 style={{ fontSize: "2.2rem", lineHeight: 1.2, margin: "0 0 1rem" }}>{post.title}</h1>
|
|
252
|
+
{post.date ? <p style={{ color: "var(--sa-muted, #94a3b8)", margin: "0 0 1.5rem" }}>{fmtDate(post.date, locale)}</p> : null}
|
|
253
|
+
{/* Тело уже санитайзено при доставке; id-якоря добавлены buildToc при рендере. */}
|
|
254
|
+
<div className="seoagent-blog-body" dangerouslySetInnerHTML={{ __html: html }} />
|
|
255
|
+
</article>
|
|
256
|
+
</div>
|
|
154
257
|
);
|
|
155
258
|
}
|
|
156
259
|
|
package/dist/blog-toc.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SeoBlogTocSpy = SeoBlogTocSpy;
|
|
4
|
+
// Прогрессивное улучшение оглавления статьи: ссылки уже отрендерены сервером (работают без JS как
|
|
5
|
+
// якоря). Этот компонент лишь ДОБАВЛЯЕТ: подсветку активного раздела при скролле (IntersectionObserver)
|
|
6
|
+
// и плавную прокрутку по клику. Ничего не рендерит (return null). Fail-safe: при отсутствии заголовков
|
|
7
|
+
// или API просто ничего не делает.
|
|
8
|
+
const react_1 = require("react");
|
|
9
|
+
function SeoBlogTocSpy({ ids }) {
|
|
10
|
+
const key = ids.join(",");
|
|
11
|
+
(0, react_1.useEffect)(() => {
|
|
12
|
+
if (!ids.length || typeof document === "undefined")
|
|
13
|
+
return;
|
|
14
|
+
const links = new Map();
|
|
15
|
+
document.querySelectorAll("[data-toc-link]").forEach((a) => {
|
|
16
|
+
const id = a.getAttribute("data-toc-link");
|
|
17
|
+
if (id)
|
|
18
|
+
links.set(id, a);
|
|
19
|
+
});
|
|
20
|
+
const heads = ids.map((id) => document.getElementById(id)).filter(Boolean);
|
|
21
|
+
if (!heads.length)
|
|
22
|
+
return;
|
|
23
|
+
let active = "";
|
|
24
|
+
const setActive = (id) => {
|
|
25
|
+
if (id === active)
|
|
26
|
+
return;
|
|
27
|
+
active = id;
|
|
28
|
+
links.forEach((a, lid) => {
|
|
29
|
+
const on = lid === id;
|
|
30
|
+
a.classList.toggle("is-active", on);
|
|
31
|
+
if (on)
|
|
32
|
+
a.setAttribute("aria-current", "true");
|
|
33
|
+
else
|
|
34
|
+
a.removeAttribute("aria-current");
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
const reduce = typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
38
|
+
const onClick = (e) => {
|
|
39
|
+
const a = e.currentTarget;
|
|
40
|
+
const id = a.getAttribute("data-toc-link");
|
|
41
|
+
const el = id ? document.getElementById(id) : null;
|
|
42
|
+
if (!el)
|
|
43
|
+
return;
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
el.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "start" });
|
|
46
|
+
try {
|
|
47
|
+
history.replaceState(null, "", `#${id}`);
|
|
48
|
+
}
|
|
49
|
+
catch { /* noop */ }
|
|
50
|
+
setActive(id);
|
|
51
|
+
};
|
|
52
|
+
links.forEach((a) => a.addEventListener("click", onClick));
|
|
53
|
+
let io = null;
|
|
54
|
+
if (typeof IntersectionObserver !== "undefined") {
|
|
55
|
+
io = new IntersectionObserver((entries) => {
|
|
56
|
+
const vis = entries.filter((en) => en.isIntersecting)
|
|
57
|
+
.sort((x, y) => x.boundingClientRect.top - y.boundingClientRect.top);
|
|
58
|
+
if (vis[0])
|
|
59
|
+
setActive(vis[0].target.id);
|
|
60
|
+
}, { rootMargin: "-88px 0px -70% 0px", threshold: 0 });
|
|
61
|
+
heads.forEach((h) => io.observe(h));
|
|
62
|
+
}
|
|
63
|
+
return () => {
|
|
64
|
+
io?.disconnect();
|
|
65
|
+
links.forEach((a) => a.removeEventListener("click", onClick));
|
|
66
|
+
};
|
|
67
|
+
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
68
|
+
return null;
|
|
69
|
+
}
|
package/dist/blog.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
export interface SeoBlogTocTheme {
|
|
2
|
+
enabled?: boolean;
|
|
3
|
+
variant?: string;
|
|
4
|
+
motion?: boolean;
|
|
5
|
+
top?: string;
|
|
6
|
+
css?: string;
|
|
7
|
+
}
|
|
1
8
|
export interface SeoBlogTheme {
|
|
2
9
|
accent?: string;
|
|
3
10
|
border?: string;
|
|
@@ -5,6 +12,7 @@ export interface SeoBlogTheme {
|
|
|
5
12
|
surface?: string;
|
|
6
13
|
thBg?: string;
|
|
7
14
|
radius?: string;
|
|
15
|
+
toc?: SeoBlogTocTheme;
|
|
8
16
|
}
|
|
9
17
|
export interface SeoBlogSummary {
|
|
10
18
|
slug: string;
|
|
@@ -27,6 +35,11 @@ export declare function seoBlogPost(slug: string, opts?: {
|
|
|
27
35
|
export declare function seoBlogStaticParams(opts?: {
|
|
28
36
|
localeParam?: string;
|
|
29
37
|
}): Promise<Array<Record<string, string>>>;
|
|
38
|
+
export interface SeoTocItem {
|
|
39
|
+
id: string;
|
|
40
|
+
text: string;
|
|
41
|
+
level: 2 | 3;
|
|
42
|
+
}
|
|
30
43
|
export declare function SeoBlogIndex(props: {
|
|
31
44
|
locale?: string;
|
|
32
45
|
basePath?: string;
|
package/dist/blog.js
CHANGED
|
@@ -40,6 +40,7 @@ exports.SeoBlogIndex = SeoBlogIndex;
|
|
|
40
40
|
exports.SeoBlogArticle = SeoBlogArticle;
|
|
41
41
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
42
42
|
const blog_grid_1 = require("./blog-grid");
|
|
43
|
+
const blog_toc_1 = require("./blog-toc");
|
|
43
44
|
// Файловый ридер (node:fs) грузим лениво — только когда блог реально рендерится (на сборке).
|
|
44
45
|
const blogfs = () => Promise.resolve().then(() => __importStar(require("./blog-fs")));
|
|
45
46
|
// ── Публичные data-функции (для generateMetadata / generateStaticParams) — читают файлы content/blog. ──
|
|
@@ -75,8 +76,8 @@ async function seoBlogStaticParams(opts) {
|
|
|
75
76
|
const BODY_CSS = `.seoagent-blog-body{color:inherit}
|
|
76
77
|
.seoagent-blog-body img{max-width:100%;height:auto;border-radius:var(--sa-radius,8px);margin:1rem 0}
|
|
77
78
|
.seoagent-blog-body table{width:100%;border-collapse:collapse;margin:1rem 0;font-size:.95em}
|
|
78
|
-
.seoagent-blog-body th,.seoagent-blog-body td{border:1px solid var(--sa-border
|
|
79
|
-
.seoagent-blog-body th{background:var(--sa-th-bg
|
|
79
|
+
.seoagent-blog-body th,.seoagent-blog-body td{border:1px solid var(--sa-border,color-mix(in srgb,currentColor 16%,transparent));padding:.5rem .75rem;text-align:left}
|
|
80
|
+
.seoagent-blog-body th{background:var(--sa-th-bg,color-mix(in srgb,currentColor 8%,transparent));color:inherit;font-weight:700}
|
|
80
81
|
.seoagent-blog-body h2{margin:1.75rem 0 .75rem;font-size:1.5rem;line-height:1.3}
|
|
81
82
|
.seoagent-blog-body h3{margin:1.4rem 0 .6rem;font-size:1.2rem}
|
|
82
83
|
.seoagent-blog-body p{margin:0 0 1rem}
|
|
@@ -90,6 +91,87 @@ const BODY_CSS = `.seoagent-blog-body{color:inherit}
|
|
|
90
91
|
.seoagent-blog-body figure{margin:1.25rem 0}
|
|
91
92
|
.seoagent-blog-body figcaption{color:var(--sa-muted,#94a3b8);font-size:.85em;text-align:center;margin-top:.4rem}
|
|
92
93
|
.seoagent-blog-body blockquote{margin:1rem 0;padding:.5rem 1rem;border-left:3px solid var(--sa-border,#e5e7eb);color:var(--sa-muted,#475569)}`;
|
|
94
|
+
// ── Оглавление статьи (TOC) ────────────────────────────────────────────────────────────────────
|
|
95
|
+
// Санитайзер РЕЖЕТ id у заголовков → якоря нельзя протащить из контента. Поэтому адаптер САМ строит
|
|
96
|
+
// оглавление и проставляет id при рендере (детерминированно, server-side, без сети и без зависимостей).
|
|
97
|
+
const TOC_LABEL = { ru: "Содержание", uz: "Mundarija", en: "Contents", ar: "المحتويات", tr: "İçindekiler" };
|
|
98
|
+
const TOC_MIN = 3; // меньше 3 заголовков — оглавление не показываем (не нужно)
|
|
99
|
+
// Транслит кириллицы → латиница для стабильных слагов-якорей (uzbek-latin уже латиница).
|
|
100
|
+
const TRANSLIT = {
|
|
101
|
+
а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "e", ж: "zh", з: "z", и: "i", й: "y", к: "k", л: "l", м: "m", н: "n", о: "o", п: "p",
|
|
102
|
+
р: "r", с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "ts", ч: "ch", ш: "sh", щ: "sch", ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya",
|
|
103
|
+
ў: "o", қ: "q", ғ: "g", ҳ: "h",
|
|
104
|
+
};
|
|
105
|
+
function slugify(text) {
|
|
106
|
+
const s = text.toLowerCase().split("").map((ch) => TRANSLIT[ch] ?? ch).join("")
|
|
107
|
+
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
108
|
+
return s || "section";
|
|
109
|
+
}
|
|
110
|
+
// Извлекает h2/h3, впрыскивает уникальные id в открывающие теги, возвращает {html, items}. Fail-safe.
|
|
111
|
+
function buildToc(html) {
|
|
112
|
+
const items = [];
|
|
113
|
+
const used = new Set();
|
|
114
|
+
try {
|
|
115
|
+
const out = html.replace(/<(h2|h3)(\s[^>]*)?>([\s\S]*?)<\/\1>/gi, (full, tag, attrs, inner) => {
|
|
116
|
+
const text = inner.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
|
117
|
+
if (!text)
|
|
118
|
+
return full;
|
|
119
|
+
let id = slugify(text);
|
|
120
|
+
const base = id;
|
|
121
|
+
let n = 2;
|
|
122
|
+
while (used.has(id)) {
|
|
123
|
+
id = `${base}-${n}`;
|
|
124
|
+
n++;
|
|
125
|
+
}
|
|
126
|
+
used.add(id);
|
|
127
|
+
items.push({ id, text, level: tag.toLowerCase() === "h3" ? 3 : 2 });
|
|
128
|
+
return `<${tag}${attrs || ""} id="${id}">${inner}</${tag}>`;
|
|
129
|
+
});
|
|
130
|
+
return { html: out, items };
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return { html, items: [] };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Валидатор персонального CSS оглавления (от Designer-агента). Только безопасные правила под .sa-toc:
|
|
137
|
+
// без выхода из <style>, без @import/expression/js-url. Иначе возвращаем пусто (fail-safe).
|
|
138
|
+
function safeTocCss(css) {
|
|
139
|
+
if (!css || typeof css !== "string")
|
|
140
|
+
return "";
|
|
141
|
+
if (/<\/?style|@import|expression\s*\(|javascript:|<script/i.test(css))
|
|
142
|
+
return "";
|
|
143
|
+
return css.slice(0, 4000);
|
|
144
|
+
}
|
|
145
|
+
const TOC_CSS = `.sa-article-wrap{max-width:1080px;margin:0 auto;padding:var(--sa-blog-top,clamp(5rem,8vw,7rem)) 1.5rem 3rem}
|
|
146
|
+
.sa-article-wrap.has-toc{display:grid;gap:2.5rem;grid-template-columns:1fr}
|
|
147
|
+
@media(min-width:1024px){.sa-article-wrap.has-toc{grid-template-columns:250px minmax(0,760px);justify-content:center;align-items:start}}
|
|
148
|
+
.sa-article{min-width:0;line-height:1.7}
|
|
149
|
+
.sa-article-wrap:not(.has-toc) .sa-article{max-width:760px;margin:0 auto}
|
|
150
|
+
.sa-toc{order:-1;font-size:.9rem}
|
|
151
|
+
@media(min-width:1024px){.sa-toc{position:sticky;top:var(--sa-toc-top,88px);max-height:calc(100vh - 120px);overflow:auto}}
|
|
152
|
+
.sa-toc>summary{list-style:none;font-weight:700;font-size:.95rem;margin:0 0 .75rem;display:flex;align-items:center;justify-content:space-between;gap:.5rem;color:inherit;cursor:pointer}
|
|
153
|
+
.sa-toc>summary::-webkit-details-marker{display:none}
|
|
154
|
+
.sa-toc>summary .sa-toc-chev{transition:transform .2s ease;color:var(--sa-muted,#94a3b8)}
|
|
155
|
+
.sa-toc[open]>summary .sa-toc-chev{transform:rotate(90deg)}
|
|
156
|
+
@media(min-width:1024px){.sa-toc>summary{pointer-events:none}.sa-toc>summary .sa-toc-chev{display:none}}
|
|
157
|
+
.sa-toc-list{display:flex;flex-direction:column;gap:.1rem}
|
|
158
|
+
.sa-toc-list a{display:block;text-decoration:none;color:var(--sa-muted,#64748b);padding:.35rem .6rem;border-radius:var(--sa-radius,8px);line-height:1.4;transition:color .15s ease,background .15s ease,border-color .15s ease}
|
|
159
|
+
.sa-toc-list a[data-level="3"]{padding-left:1.4rem;font-size:.85em}
|
|
160
|
+
.sa-toc-list a:hover{color:inherit}
|
|
161
|
+
.sa-toc-list a.is-active{color:var(--sa-accent,#2563eb)}
|
|
162
|
+
.sa-toc[data-sa-toc="minimal"] .sa-toc-list{border-left:2px solid var(--sa-border,#e5e7eb);padding-left:.2rem}
|
|
163
|
+
.sa-toc[data-sa-toc="minimal"] .sa-toc-list a{border-left:2px solid transparent;margin-left:-.2rem;border-radius:0}
|
|
164
|
+
.sa-toc[data-sa-toc="minimal"] .sa-toc-list a.is-active{border-left-color:var(--sa-accent,#2563eb);font-weight:600}
|
|
165
|
+
.sa-toc[data-sa-toc="bordered"]{border:1px solid var(--sa-border,#e5e7eb);border-radius:var(--sa-radius,12px);padding:1rem;background:var(--sa-surface,transparent)}
|
|
166
|
+
.sa-toc[data-sa-toc="bordered"] .sa-toc-list a.is-active{background:var(--sa-th-bg,color-mix(in srgb,currentColor 8%,transparent))}
|
|
167
|
+
.sa-toc[data-sa-toc="pill"] .sa-toc-list a.is-active{background:var(--sa-accent,#2563eb);color:#fff}
|
|
168
|
+
.sa-toc[data-sa-toc="numbered"] .sa-toc-list{counter-reset:sa-toc}
|
|
169
|
+
.sa-toc[data-sa-toc="numbered"] .sa-toc-list a[data-level="2"]{counter-increment:sa-toc}
|
|
170
|
+
.sa-toc[data-sa-toc="numbered"] .sa-toc-list a[data-level="2"]::before{content:counter(sa-toc) ". ";color:var(--sa-muted,#94a3b8)}
|
|
171
|
+
.sa-toc[data-sa-toc="numbered"] .sa-toc-list a.is-active::before{color:var(--sa-accent,#2563eb)}
|
|
172
|
+
.seoagent-blog-body h2,.seoagent-blog-body h3{scroll-margin-top:var(--sa-toc-top,88px)}
|
|
173
|
+
@media(prefers-reduced-motion:no-preference){.sa-toc[data-motion="on"] .sa-toc-list{animation:sa-toc-in .45s ease both}}
|
|
174
|
+
@keyframes sa-toc-in{from{opacity:0;transform:translateX(-6px)}to{opacity:1;transform:none}}`;
|
|
93
175
|
// Тема → CSS-переменные пакета (--sa-*). Кладём на корень блока — они каскадируются вниз к
|
|
94
176
|
// ссылкам/карточкам/телу. Отдаём только заданные ключи (пустые не трогаем → останется fallback).
|
|
95
177
|
function themeStyle(theme) {
|
|
@@ -147,7 +229,15 @@ async function SeoBlogIndex(props) {
|
|
|
147
229
|
}
|
|
148
230
|
// Одна статья. Принимает пост (из seoBlogPost) — так страница может сделать notFound()/метаданные.
|
|
149
231
|
function SeoBlogArticle({ post, locale }) {
|
|
150
|
-
|
|
232
|
+
const toc = post.theme?.toc ?? {};
|
|
233
|
+
// Оглавление строим ВСЕГДА (нужны id-якоря в тексте), а показываем только если вкл. и ≥TOC_MIN заголовков.
|
|
234
|
+
const { html, items } = buildToc(post.bodyHtml);
|
|
235
|
+
const showToc = toc.enabled !== false && items.length >= TOC_MIN;
|
|
236
|
+
const variant = toc.variant || "minimal";
|
|
237
|
+
const motion = toc.motion === false ? "off" : "on";
|
|
238
|
+
const label = L(TOC_LABEL, locale ?? "", "Contents");
|
|
239
|
+
const rootStyle = { ...themeStyle(post.theme), ...(toc.top ? { ["--sa-toc-top"]: toc.top } : {}) };
|
|
240
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: showToc ? "sa-article-wrap has-toc" : "sa-article-wrap", style: rootStyle, children: [(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: BODY_CSS + TOC_CSS + (showToc ? safeTocCss(toc.css) : "") } }), showToc ? ((0, jsx_runtime_1.jsxs)("details", { className: "sa-toc", "data-sa-toc": variant, "data-motion": motion, open: true, children: [(0, jsx_runtime_1.jsxs)("summary", { children: [label, (0, jsx_runtime_1.jsx)("span", { className: "sa-toc-chev", "aria-hidden": "true", children: "\u203A" })] }), (0, jsx_runtime_1.jsx)("nav", { className: "sa-toc-list", "aria-label": label, children: items.map((it) => ((0, jsx_runtime_1.jsx)("a", { href: `#${it.id}`, "data-toc-link": it.id, "data-level": it.level, children: it.text }, it.id))) }), (0, jsx_runtime_1.jsx)(blog_toc_1.SeoBlogTocSpy, { ids: items.map((it) => it.id) })] })) : null, (0, jsx_runtime_1.jsxs)("article", { className: "sa-article", children: [post.cover ? (0, jsx_runtime_1.jsx)("img", { src: post.cover, alt: post.title, style: { width: "100%", aspectRatio: "3 / 2", objectFit: "cover", display: "block", borderRadius: "var(--sa-radius, 12px)", marginBottom: "1.5rem" } }) : null, (0, jsx_runtime_1.jsx)("h1", { style: { fontSize: "2.2rem", lineHeight: 1.2, margin: "0 0 1rem" }, children: post.title }), post.date ? (0, jsx_runtime_1.jsx)("p", { style: { color: "var(--sa-muted, #94a3b8)", margin: "0 0 1.5rem" }, children: fmtDate(post.date, locale) }) : null, (0, jsx_runtime_1.jsx)("div", { className: "seoagent-blog-body", dangerouslySetInnerHTML: { __html: html } })] })] }));
|
|
151
241
|
}
|
|
152
242
|
function fmtDate(d, locale) {
|
|
153
243
|
try {
|
package/package.json
CHANGED