@webnumseoagent/next 0.5.0 → 0.7.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/README.md +14 -0
- package/bin/cli.mjs +16 -15
- package/blog-fs.ts +164 -0
- package/blog.tsx +48 -243
- package/dist/blog-fs.d.ts +11 -0
- package/dist/blog-fs.js +199 -0
- package/dist/blog.d.ts +6 -18
- package/dist/blog.js +57 -231
- package/dist/sanitize.d.ts +1 -0
- package/dist/sanitize.js +76 -0
- package/dist/sitemap-build.js +49 -4
- package/package.json +3 -1
- package/sanitize.ts +56 -0
- package/sitemap-build.ts +16 -4
package/README.md
CHANGED
|
@@ -93,8 +93,22 @@ Vercel и `git push`. **Проверь `git diff`** перед коммитом
|
|
|
93
93
|
}
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
+
## Блог (git-native)
|
|
97
|
+
|
|
98
|
+
Блог рендерится **из файлов репозитория** — папка `content/blog` (формат blogfs v1).
|
|
99
|
+
Статьи туда пишет платформа SEO Agent через git-доставку (коммит/PR); Supabase не нужен.
|
|
100
|
+
|
|
101
|
+
- Роуты ставит `npx @webnumseoagent/next blog` (мультиязычный сайт → `/[locale]/blog`,
|
|
102
|
+
под layout сайта) или `init` (по умолчанию, `--no-blog` чтобы отключить).
|
|
103
|
+
- Рендер **полностью статический** (`dynamic = "force-static"`, `dynamicParams = false`):
|
|
104
|
+
файлы читаются только на сборке, в рантайме ничего не запрашивается. Новый/изменённый
|
|
105
|
+
контент = git-коммит → редеплой пересобирает страницы.
|
|
106
|
+
- Тема оформления (фирменный цвет) и канонический URL-префикс берутся из `content/blog/.blogfs.json`.
|
|
107
|
+
- Sitemap блога добавляется автоматически (раздел `sitemap/blog.xml`).
|
|
108
|
+
|
|
96
109
|
## Гарантии
|
|
97
110
|
- **Fail-safe:** при недоступности платформы адаптер возвращает твои дефолты и
|
|
98
111
|
разрешающий robots — сайт не падает и не уходит в `noindex`.
|
|
112
|
+
- **Блог без сети:** статьи читаются из файлов репо на сборке — ни Supabase, ни запросов к платформе.
|
|
99
113
|
- **Защита от деиндекса:** `index:false` применяется только если он явно задан в конфиге.
|
|
100
114
|
- **Generic:** ничего не завязано на конкретный сайт — только env + маршрут/локаль.
|
package/bin/cli.mjs
CHANGED
|
@@ -40,8 +40,8 @@ async function anyExistsRel(paths) {
|
|
|
40
40
|
return false;
|
|
41
41
|
}
|
|
42
42
|
// Пишет НАШ генерируемый роут: создаёт, если нет; ПЕРЕЗАПИСЫВАЕТ, если файл уже наш (импортит наши
|
|
43
|
-
// blog-компоненты) и содержимое изменилось (напр. старый
|
|
44
|
-
//
|
|
43
|
+
// blog-компоненты) и содержимое изменилось (напр. старый динамический роут с ?show → обновляем на
|
|
44
|
+
// актуальный статический); НЕ трогает собственный блог сайта (файл без наших импортов).
|
|
45
45
|
async function writeOurRoute(p, content) {
|
|
46
46
|
const cur = await fs.readFile(p, "utf8").catch(() => null);
|
|
47
47
|
if (cur === null) {
|
|
@@ -131,7 +131,7 @@ async function init() {
|
|
|
131
131
|
// Вендор-файлы адаптера — генерируемые (их не правят руками): при повторном init ОБНОВЛЯЕМ,
|
|
132
132
|
// если содержимое изменилось. Иначе апгрейд оставит старый client.ts без новых экспортов
|
|
133
133
|
// (напр. seoMerchantFeedXml), а свежесозданный роут импортнёт несуществующий символ → build падёт.
|
|
134
|
-
for (const f of ["client.ts", "jsonld.tsx", "sitemap-xsl.ts", "sitemap-build.ts", "redirects.ts", "blog.tsx"]) {
|
|
134
|
+
for (const f of ["client.ts", "jsonld.tsx", "sitemap-xsl.ts", "sitemap-build.ts", "redirects.ts", "blog.tsx", "blog-fs.ts", "sanitize.ts"]) {
|
|
135
135
|
const dest = path.join(vendor, f);
|
|
136
136
|
const src = await fs.readFile(path.join(pkgRoot, f));
|
|
137
137
|
const cur = await fs.readFile(dest).catch(() => null);
|
|
@@ -409,7 +409,7 @@ async function init() {
|
|
|
409
409
|
const mdParam = ext === "ts" ? "{ params }: { params: { slug: string } }" : "{ params }";
|
|
410
410
|
const madeIndex = await ensureFile(
|
|
411
411
|
path.join(root, appDir, `blog/page.${jsxExt}`),
|
|
412
|
-
`import { SeoBlogIndex } from "${blogFor("blog/page.tsx")}";\nexport const
|
|
412
|
+
`import { SeoBlogIndex } from "${blogFor("blog/page.tsx")}";\nexport const dynamic = "force-static";\nexport default function BlogPage() {\n return <SeoBlogIndex title="Блог" />;\n}\n`,
|
|
413
413
|
);
|
|
414
414
|
// У сайта уже может быть app/blog/[иной-сегмент] — Next не даёт два разных динамических имени
|
|
415
415
|
// на один путь. Тогда наш /blog/[slug] не создаём (иначе билд упадёт), а печатаем ручную инструкцию.
|
|
@@ -426,11 +426,11 @@ async function init() {
|
|
|
426
426
|
} else {
|
|
427
427
|
madeArticle = await ensureFile(
|
|
428
428
|
path.join(root, appDir, `blog/[slug]/page.${jsxExt}`),
|
|
429
|
-
`import { notFound } from "next/navigation";\nimport { seoBlogPost, SeoBlogArticle } from "${blogFor("blog/[slug]/page.tsx")}";\nexport const
|
|
429
|
+
`import { notFound } from "next/navigation";\nimport { seoBlogPost, seoBlogStaticParams, SeoBlogArticle } from "${blogFor("blog/[slug]/page.tsx")}";\nexport const dynamic = "force-static";\nexport const dynamicParams = false;\n\n// Полностью статический рендер: все статьи пре-рендерятся на сборке из файлов content/blog. Контент\n// меняется git-коммитом → редеплой. dynamicParams=false: неизвестный слаг → 404 (без рантайм-чтения).\nexport async function generateStaticParams() {\n return seoBlogStaticParams();\n}\n\nexport async function generateMetadata(${mdParam}) {\n const post = await seoBlogPost(params.slug);\n if (!post) return {};\n return { title: post.title, description: post.excerpt, openGraph: { images: post.cover ? [post.cover] : [] } };\n}\n\nexport default async function BlogPostPage(${mdParam}) {\n const post = await seoBlogPost(params.slug);\n if (!post) notFound();\n return <SeoBlogArticle post={post} />;\n}\n`,
|
|
430
430
|
);
|
|
431
431
|
}
|
|
432
432
|
if (madeIndex || madeArticle) {
|
|
433
|
-
log(`\n \x1b[32m✓\x1b[0m Блог: роуты /blog (
|
|
433
|
+
log(`\n \x1b[32m✓\x1b[0m Блог: роуты /blog (статический рендер из файлов content/blog) созданы. Включи git-доставку блога на платформе. (Отключить блог: \x1b[1m--no-blog\x1b[0m)`);
|
|
434
434
|
} else {
|
|
435
435
|
log(`\n \x1b[33mℹ\x1b[0m Блог: у сайта уже есть роуты /blog — не трогаю.`);
|
|
436
436
|
}
|
|
@@ -691,16 +691,17 @@ async function blogPage() {
|
|
|
691
691
|
blogDynConflict = names.some((n) => /^\[.*\]$/.test(n) && n !== "[slug]");
|
|
692
692
|
}
|
|
693
693
|
|
|
694
|
-
// index-роут (список): в [locale] прокидываем locale + basePath
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
694
|
+
// index-роут (список): в [locale] прокидываем locale + basePath. СТАТИЧЕСКИЙ (Фаза 1 git-native):
|
|
695
|
+
// без searchParams (иначе роут стал бы динамическим и в рантайме Vercel не увидел бы файлы content/blog).
|
|
696
|
+
const idxSig = param
|
|
697
|
+
? (ext === "ts" ? `{ params }: { params: { ${param}: string } }` : "{ params }")
|
|
698
|
+
: "";
|
|
698
699
|
const idxBody = param
|
|
699
|
-
? `<SeoBlogIndex locale={params.${param}} basePath={\`/\${params.${param}}/blog\`}
|
|
700
|
-
: `<SeoBlogIndex
|
|
700
|
+
? `<SeoBlogIndex locale={params.${param}} basePath={\`/\${params.${param}}/blog\`} />`
|
|
701
|
+
: `<SeoBlogIndex />`;
|
|
701
702
|
await writeOurRoute(
|
|
702
703
|
path.join(root, appDir, under, `blog/page.${jsxExt}`),
|
|
703
|
-
`import { SeoBlogIndex } from "${blogFor(`${under}blog/page.tsx`)}";\nexport const
|
|
704
|
+
`import { SeoBlogIndex } from "${blogFor(`${under}blog/page.tsx`)}";\nexport const dynamic = "force-static";\nexport default function BlogPage(${idxSig}) {\n return ${idxBody};\n}\n`,
|
|
704
705
|
);
|
|
705
706
|
|
|
706
707
|
if (blogDynConflict) {
|
|
@@ -713,11 +714,11 @@ async function blogPage() {
|
|
|
713
714
|
const localeProp = param ? ` locale={params.${param}}` : "";
|
|
714
715
|
await writeOurRoute(
|
|
715
716
|
path.join(root, appDir, under, `blog/[slug]/page.${jsxExt}`),
|
|
716
|
-
`import { notFound } from "next/navigation";\nimport { seoBlogPost, SeoBlogArticle } from "${blogFor(`${under}blog/[slug]/page.tsx`)}";\nexport const
|
|
717
|
+
`import { notFound } from "next/navigation";\nimport { seoBlogPost, seoBlogStaticParams, SeoBlogArticle } from "${blogFor(`${under}blog/[slug]/page.tsx`)}";\nexport const dynamic = "force-static";\nexport const dynamicParams = false;\n\n// Полностью статический рендер: все статьи пре-рендерятся на сборке из файлов content/blog. Контент\n// меняется git-коммитом → редеплой. dynamicParams=false: неизвестный слаг → 404 (без рантайм-чтения).\nexport async function generateStaticParams() {\n return seoBlogStaticParams();\n}\n\nexport async function generateMetadata(${artSig}) {\n const post = await seoBlogPost(params.slug${localeArg});\n if (!post) return {};\n return { title: post.title, description: post.excerpt, openGraph: { images: post.cover ? [post.cover] : [] } };\n}\n\nexport default async function BlogPostPage(${artSig}) {\n const post = await seoBlogPost(params.slug${localeArg});\n if (!post) notFound();\n return <SeoBlogArticle post={post}${localeProp} />;\n}\n`,
|
|
717
718
|
);
|
|
718
719
|
}
|
|
719
720
|
|
|
720
|
-
log(`\n \x1b[32m✓\x1b[0m Готово. Проверь \x1b[36mgit diff\x1b[0m, затем commit + push. Блог: \x1b[1m${localeDir ? `/${param}/blog` : "/blog"}\x1b[0m — дизайн/шапка с сайта, список 4
|
|
721
|
+
log(`\n \x1b[32m✓\x1b[0m Готово. Проверь \x1b[36mgit diff\x1b[0m, затем commit + push. Блог: \x1b[1m${localeDir ? `/${param}/blog` : "/blog"}\x1b[0m — дизайн/шапка с сайта, список 4-в-ряд, статический рендер из файлов.\n`);
|
|
721
722
|
}
|
|
722
723
|
|
|
723
724
|
if (cmd === "init") {
|
package/blog-fs.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Файловый источник блога (Фаза 4 blogfs): читает статьи из репозитория сайта (папка content/blog,
|
|
2
|
+
// формат blogfs v1 — см. docs/blog-git-delivery/FORMAT.md) на СБОРКЕ, без сети и без Supabase.
|
|
3
|
+
// Отдаёт те же формы, что и supabase-источник (SeoBlogSummary / SeoBlogPostFull) — blog.tsx роутит.
|
|
4
|
+
// FAIL-SAFE: любая ошибка/битый файл → пропуск, сборка НИКОГДА не падает.
|
|
5
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { sanitizeDeliveryHtml } from "./sanitize";
|
|
8
|
+
import type { SeoBlogSummary, SeoBlogPostFull, SeoBlogTheme } from "./blog";
|
|
9
|
+
|
|
10
|
+
const BLOG_DIR = process.env.SEOAGENT_BLOG_DIR ?? "content/blog";
|
|
11
|
+
const SUPPORTED_MAJOR = 1; // schema "seoagent.blog/v1"
|
|
12
|
+
|
|
13
|
+
function root(): string {
|
|
14
|
+
return join(process.cwd(), BLOG_DIR);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface LocaleEntry { title?: string; excerpt?: string | null; metaTitle?: string | null; body?: string }
|
|
18
|
+
interface Meta {
|
|
19
|
+
schema?: string; slug?: string; status?: string; defaultLocale?: string; bodyFormat?: string;
|
|
20
|
+
coverImage?: string; publishedAt?: string | null; locales?: Record<string, LocaleEntry>; features?: string[];
|
|
21
|
+
}
|
|
22
|
+
// Возможности формата, которые НЕ меняют рендер тела (ридер v1 их спокойно поддерживает). Любую иную
|
|
23
|
+
// запись в features[] версия-пиннутый ридер трактует как «не умею» → пропуск статьи (forward-compat).
|
|
24
|
+
// source = лишь игнорируемый .tiptap.json сайдкар; local-assets = картинки лежат в public/ и уже
|
|
25
|
+
// указаны в теле локальным URL — ридеру ничего делать не нужно (тоже body-нейтрально).
|
|
26
|
+
const KNOWN_FEATURES = new Set(["source", "local-assets"]);
|
|
27
|
+
|
|
28
|
+
// Мажор схемы из строки "seoagent.blog/vN" (или число). null — не наш формат.
|
|
29
|
+
function schemaMajor(schema: unknown): number | null {
|
|
30
|
+
if (typeof schema === "number") return schema;
|
|
31
|
+
if (typeof schema === "string") { const m = schema.match(/\/v(\d+)$/); if (m) return parseInt(m[1], 10); }
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readDescriptor(): { defaultLocale?: string; locales?: string[]; schema?: string; urlPrefix?: string; theme?: SeoBlogTheme } | null {
|
|
36
|
+
try { return JSON.parse(readFileSync(join(root(), ".blogfs.json"), "utf8")); } catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Тема оформления блога из дескриптора (git-режим, Фаза 3): ридер применяет её в --sa-* БЕЗ config-API.
|
|
40
|
+
// Валиден только объект с строковым accent (иначе игнор → дефолтные --sa-*).
|
|
41
|
+
export function fsTheme(): SeoBlogTheme | undefined {
|
|
42
|
+
const t = readDescriptor()?.theme;
|
|
43
|
+
return t && typeof t === "object" && typeof t.accent === "string" && t.accent ? t : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Канонический префикс публичных URL блога. Приоритет — явный descriptor.urlPrefix (пишет платформа);
|
|
47
|
+
// фолбэк для старых дескрипторов без него: <2 локалей → "" (одноязычный /blog), иначе "/<defaultLocale>".
|
|
48
|
+
function urlPrefix(): string {
|
|
49
|
+
const d = readDescriptor();
|
|
50
|
+
if (!d) return "";
|
|
51
|
+
if (typeof d.urlPrefix === "string") return d.urlPrefix.replace(/\/+$/, "");
|
|
52
|
+
const locales = Array.isArray(d.locales) ? d.locales.map((l) => String(l).trim()).filter(Boolean) : [];
|
|
53
|
+
if (locales.length < 2) return "";
|
|
54
|
+
const dflt = (typeof d.defaultLocale === "string" && d.defaultLocale.trim()) || locales[0];
|
|
55
|
+
return dflt ? `/${dflt}` : "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Есть ли файловый блог в репо (наличие .blogfs.json = git-сайт с ≥1 доставкой). Сигнал авто-детекта.
|
|
59
|
+
export function fsAvailable(): boolean {
|
|
60
|
+
return readDescriptor() !== null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function fsDefaultLocale(): string {
|
|
64
|
+
const d = readDescriptor()?.defaultLocale;
|
|
65
|
+
return typeof d === "string" ? d : ""; // битый дескриптор мог дать не-строку → не роняем split ниже
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Собрать meta.json всех статей (glob по папкам). Пропускает битые/незнакомые версии/черновики.
|
|
69
|
+
function listMetas(): { dir: string; meta: Meta }[] {
|
|
70
|
+
const r = root();
|
|
71
|
+
let names: string[];
|
|
72
|
+
try { names = readdirSync(r, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); }
|
|
73
|
+
catch { return []; }
|
|
74
|
+
const out: { dir: string; meta: Meta }[] = [];
|
|
75
|
+
for (const name of names) {
|
|
76
|
+
if (name.startsWith(".") || name.startsWith("_")) continue; // служебные (assets и т.п.)
|
|
77
|
+
let meta: Meta;
|
|
78
|
+
try { meta = JSON.parse(readFileSync(join(r, name, "meta.json"), "utf8")); }
|
|
79
|
+
catch { continue; } // нет meta.json / битый JSON → пропуск (не роняем сборку)
|
|
80
|
+
// Валидный JSON может быть null/массивом/примитивом — доступ к полям тогда бросит. Пропускаем.
|
|
81
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) continue;
|
|
82
|
+
const major = schemaMajor(meta.schema);
|
|
83
|
+
if (major === null || major > SUPPORTED_MAJOR) { // незнакомая/будущая версия → пропуск с предупреждением
|
|
84
|
+
try { console.warn(`[seoagent blogfs] пропуск ${name}: несовместимая версия схемы (${String(meta.schema)})`); } catch { /* noop */ }
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Незнакомая body-возможность (mdx/frontmatter/…) в том же мажоре → ридер её не умеет → пропуск.
|
|
88
|
+
if (Array.isArray(meta.features) && meta.features.some((f) => !KNOWN_FEATURES.has(f))) {
|
|
89
|
+
try { console.warn(`[seoagent blogfs] пропуск ${name}: неподдерживаемая возможность (${meta.features.join(",")})`); } catch { /* noop */ }
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (meta.status !== "published" || !meta.slug || !meta.locales) continue;
|
|
93
|
+
out.push({ dir: name, meta });
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Запись локали: точная → без региона (en-US→en) → defaultLocale → первая доступная.
|
|
99
|
+
function pickLocale(meta: Meta, locale: string): LocaleEntry {
|
|
100
|
+
const locs = meta.locales ?? {};
|
|
101
|
+
const loc = typeof locale === "string" ? locale : ""; // на всякий случай — locale мог прийти не строкой
|
|
102
|
+
const base = loc ? loc.split("-")[0] : "";
|
|
103
|
+
return locs[loc] || (base && locs[base]) || (meta.defaultLocale ? locs[meta.defaultLocale] : undefined) || locs[Object.keys(locs)[0]] || {};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function summary(meta: Meta, locale: string): SeoBlogSummary {
|
|
107
|
+
const e = pickLocale(meta, locale);
|
|
108
|
+
return {
|
|
109
|
+
slug: meta.slug ?? "",
|
|
110
|
+
title: e.title ?? "",
|
|
111
|
+
excerpt: e.excerpt ?? "",
|
|
112
|
+
cover: meta.coverImage ?? null,
|
|
113
|
+
date: meta.publishedAt ?? null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Список статей для локали, по publishedAt убыв., до limit.
|
|
118
|
+
export function fsBlogList(locale: string, limit: number): SeoBlogSummary[] {
|
|
119
|
+
const rows = listMetas().map(({ meta }) => summary(meta, locale)).filter((p) => p.slug && p.title);
|
|
120
|
+
rows.sort((a, b) => (b.date ?? "").localeCompare(a.date ?? "")); // publishedAt desc (ISO — код-юнит ок)
|
|
121
|
+
return rows.slice(0, Math.max(0, limit));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Одна статья по slug (ищем по meta.slug — папка может быть по id при pathStrategy=id).
|
|
125
|
+
export function fsBlogPost(slug: string, locale: string): SeoBlogPostFull | null {
|
|
126
|
+
const hit = listMetas().find((x) => x.meta.slug === slug);
|
|
127
|
+
if (!hit) return null;
|
|
128
|
+
const e = pickLocale(hit.meta, locale);
|
|
129
|
+
let bodyRaw = "";
|
|
130
|
+
// Имя файла тела берётся из meta.json репо — только безопасное имя (без "/" и ".."), защита от traversal.
|
|
131
|
+
if (e.body && /^[A-Za-z0-9._-]+$/.test(e.body)) { try { bodyRaw = readFileSync(join(root(), hit.dir, e.body), "utf8"); } catch { bodyRaw = ""; } }
|
|
132
|
+
const s = summary(hit.meta, locale);
|
|
133
|
+
return {
|
|
134
|
+
...s,
|
|
135
|
+
// Тело уже санитайзено при доставке; чистим повторно (защита-в-глубину). bodyFormat="mdx" появится позже.
|
|
136
|
+
bodyHtml: sanitizeDeliveryHtml(bodyRaw),
|
|
137
|
+
theme: fsTheme(), // тема из .blogfs.json (Фаза 3) — фирменные цвета блога без запроса к платформе
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Есть ли вообще папка блога (для мягких проверок).
|
|
142
|
+
export function fsDirExists(): boolean {
|
|
143
|
+
try { return existsSync(root()); } catch { return false; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Записи блога для sitemap (Фаза 2): страница списка + канонический URL каждой статьи, на СБОРКЕ из файлов.
|
|
147
|
+
// loc = origin + urlPrefix + "/blog[/<slug>]"; lastmod = meta.publishedAt (ISO), если есть. Одна каноническая
|
|
148
|
+
// ссылка на статью (префикс дефолтной локали) — как платформа пингует IndexNow; локали-альтернативы позже (hreflang).
|
|
149
|
+
// Fail-safe: любая ошибка → []. origin — https://домен (без хвостового слэша), берётся из карты сайта.
|
|
150
|
+
export function fsBlogSitemapEntries(origin: string): { loc: string; lastmod?: string }[] {
|
|
151
|
+
try {
|
|
152
|
+
const base = (origin || "").replace(/\/+$/, "");
|
|
153
|
+
const pref = urlPrefix();
|
|
154
|
+
const metas = listMetas();
|
|
155
|
+
if (!metas.length) return [];
|
|
156
|
+
const out: { loc: string; lastmod?: string }[] = [{ loc: `${base}${pref}/blog` }];
|
|
157
|
+
for (const { meta } of metas) {
|
|
158
|
+
if (!meta.slug) continue;
|
|
159
|
+
const lm = typeof meta.publishedAt === "string" && meta.publishedAt ? meta.publishedAt : undefined;
|
|
160
|
+
out.push({ loc: `${base}${pref}/blog/${meta.slug}`, lastmod: lm });
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
} catch { return []; }
|
|
164
|
+
}
|
package/blog.tsx
CHANGED
|
@@ -1,235 +1,49 @@
|
|
|
1
|
-
// @webnumseoagent/next/blog — рендер блога код-сайта (
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// (anon-ключ + public-read RLS) и рендерит обложку + заголовок + тело (картинки/таблицы/ссылки).
|
|
1
|
+
// @webnumseoagent/next/blog — рендер блога код-сайта из ФАЙЛОВ репозитория (git-native, blogfs v1).
|
|
2
|
+
// Статьи лежат в content/blog (формат blogfs — см. docs/blog-git-delivery/FORMAT.md). Ридер blog-fs.ts
|
|
3
|
+
// читает их на СБОРКЕ через node:fs, БЕЗ сети и БЕЗ Supabase. Тема оформления — из .blogfs.json.
|
|
5
4
|
//
|
|
6
|
-
// Тело статьи уже САНИТАЙЗЕНО
|
|
7
|
-
//
|
|
5
|
+
// Тело статьи уже САНИТАЙЗЕНО платформой при доставке, поэтому рендерится как HTML (dangerouslySetInnerHTML) —
|
|
6
|
+
// граница доверия проходит по доставке, не по рендеру. Ридер санитайзит повторно (защита-в-глубину).
|
|
8
7
|
//
|
|
9
|
-
// Все функции FAIL-SAFE: при
|
|
8
|
+
// Все функции FAIL-SAFE: при ошибке/отсутствии файлов возвращают пусто — блог не покажет контент,
|
|
10
9
|
// сборка/рендер сайта НИКОГДА не падает.
|
|
11
10
|
//
|
|
12
|
-
//
|
|
13
|
-
// app/blog/page.tsx:
|
|
14
|
-
// app/blog/[slug]/page.tsx:
|
|
15
|
-
// return <SeoBlogArticle post={post} />
|
|
11
|
+
// Роуты ставит `npx @webnumseoagent/next blog` (или init): статический рендер (dynamic="force-static").
|
|
12
|
+
// app/[locale]/blog/page.tsx: <SeoBlogIndex locale={params.locale} basePath={`/${params.locale}/blog`} />
|
|
13
|
+
// app/[locale]/blog/[slug]/page.tsx: const post = await seoBlogPost(params.slug); if(!post) notFound(); <SeoBlogArticle post={post} />
|
|
16
14
|
|
|
17
15
|
import type { CSSProperties } from "react";
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
|
|
22
|
-
const REVALIDATE = 300;
|
|
17
|
+
// Файловый ридер (node:fs) грузим лениво — только когда блог реально рендерится (на сборке).
|
|
18
|
+
const blogfs = () => import("./blog-fs");
|
|
23
19
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
id?: string; image?: string; published?: string; publishedAt?: string;
|
|
27
|
-
}
|
|
28
|
-
// Тема блога от платформы (config-API): цвета/скругления как ВАЛИДНЫЕ CSS-значения, которые пакет
|
|
29
|
-
// кладёт в свои переменные --sa-*. Сейчас платформа шлёт только accent (бренд-цвет владельца);
|
|
30
|
-
// border/surface/muted/radius зарезервированы под будущий ИИ-профиль дизайна сайта.
|
|
20
|
+
// Тема блога: цвета/скругления как ВАЛИДНЫЕ CSS-значения → пакет кладёт их в свои --sa-*. Приходит из
|
|
21
|
+
// .blogfs.json (платформа пишет бренд-цвет владельца в accent + опц. токены оформления сайта).
|
|
31
22
|
export interface SeoBlogTheme { accent?: string; border?: string; muted?: string; surface?: string; thBg?: string; radius?: string }
|
|
32
|
-
export interface SeoBlogConfig {
|
|
33
|
-
supabaseUrl: string; anonKey: string; table: string;
|
|
34
|
-
format: "html" | "minimarkup"; locales: string[]; mapping: SeoBlogMapping; theme?: SeoBlogTheme;
|
|
35
|
-
}
|
|
36
23
|
export interface SeoBlogSummary { slug: string; title: string; excerpt: string; cover: string | null; date: string | null }
|
|
37
24
|
export interface SeoBlogPostFull extends SeoBlogSummary { bodyHtml: string; theme?: SeoBlogTheme }
|
|
38
25
|
|
|
39
|
-
|
|
40
|
-
return (cfg.locales ?? []).filter(Boolean)[0] ?? "";
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Публичный конфиг блога с платформы (кэш ISR + тег seo-<site> — сбрасывается ревалидацией).
|
|
44
|
-
async function fetchBlogConfig(): Promise<SeoBlogConfig | null> {
|
|
45
|
-
if (!API || !SITE || !TOKEN) return null;
|
|
46
|
-
try {
|
|
47
|
-
const r = await fetch(`${API}/api/seo/${SITE}/blog`, {
|
|
48
|
-
headers: { "x-seo-agent-key": TOKEN },
|
|
49
|
-
next: { revalidate: REVALIDATE, tags: [`seo-${SITE}`, `seo-blog-${SITE}`] },
|
|
50
|
-
signal: AbortSignal.timeout(5000),
|
|
51
|
-
});
|
|
52
|
-
if (!r.ok) return null;
|
|
53
|
-
const d = (await r.json()) as { blog?: SeoBlogConfig | null };
|
|
54
|
-
const b = d?.blog ?? null;
|
|
55
|
-
if (!b?.supabaseUrl || !b?.anonKey || !b?.table || !b?.mapping?.title || !b?.mapping?.body) return null;
|
|
56
|
-
return b;
|
|
57
|
-
} catch {
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Запрос к REST API Supabase клиента (anon). Возвращает массив строк либо null.
|
|
63
|
-
async function supaGet(cfg: SeoBlogConfig, url: string): Promise<Record<string, unknown>[] | null> {
|
|
64
|
-
try {
|
|
65
|
-
const r = await fetch(url, {
|
|
66
|
-
headers: { apikey: cfg.anonKey, Authorization: `Bearer ${cfg.anonKey}` },
|
|
67
|
-
next: { revalidate: REVALIDATE, tags: [`seo-blog-${SITE}`] },
|
|
68
|
-
signal: AbortSignal.timeout(6000),
|
|
69
|
-
});
|
|
70
|
-
if (!r.ok) return null;
|
|
71
|
-
const d = await r.json();
|
|
72
|
-
return Array.isArray(d) ? d : null;
|
|
73
|
-
} catch {
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const S = (v: unknown): string => (v == null ? "" : String(v));
|
|
79
|
-
const base = (cfg: SeoBlogConfig): string => cfg.supabaseUrl.replace(/\/+$/, "");
|
|
80
|
-
|
|
81
|
-
async function fetchPosts(cfg: SeoBlogConfig, locale: string, limit: number): Promise<SeoBlogSummary[]> {
|
|
82
|
-
const m = cfg.mapping;
|
|
83
|
-
if (!m.id) return []; // без колонки id/slug ссылки на статьи не построить
|
|
84
|
-
const buildUrl = (suf: string) => {
|
|
85
|
-
const titleCol = m.title + suf;
|
|
86
|
-
const excerptCol = m.excerpt ? m.excerpt + suf : "";
|
|
87
|
-
const cols = [m.id, titleCol, excerptCol, m.image || "", m.publishedAt || ""].filter(Boolean).join(",");
|
|
88
|
-
let url = `${base(cfg)}/rest/v1/${encodeURIComponent(cfg.table)}?select=${cols}`;
|
|
89
|
-
if (m.published) url += `&${m.published}=eq.true`;
|
|
90
|
-
if (m.publishedAt) url += `&order=${m.publishedAt}.desc`;
|
|
91
|
-
url += `&limit=${limit}`;
|
|
92
|
-
return { url, titleCol, excerptCol };
|
|
93
|
-
};
|
|
94
|
-
const suf = locale ? `_${locale}` : "";
|
|
95
|
-
let q = buildUrl(suf);
|
|
96
|
-
let rows = await supaGet(cfg, q.url);
|
|
97
|
-
// Запрошенной локали нет среди колонок доставки (напр. языки блога не совпали с локалями сайта) →
|
|
98
|
-
// PostgREST вернул 400 (supaGet=null). Откатываемся на базовые колонки (оригинал), а не показываем пусто.
|
|
99
|
-
if (rows === null && suf) { q = buildUrl(""); rows = await supaGet(cfg, q.url); }
|
|
100
|
-
return (rows ?? [])
|
|
101
|
-
.map((r) => ({
|
|
102
|
-
slug: S(r[m.id!]),
|
|
103
|
-
title: S(r[q.titleCol]),
|
|
104
|
-
excerpt: q.excerptCol ? S(r[q.excerptCol]) : "",
|
|
105
|
-
cover: m.image ? (r[m.image] as string | null) ?? null : null,
|
|
106
|
-
date: m.publishedAt ? (r[m.publishedAt] as string | null) ?? null : null,
|
|
107
|
-
}))
|
|
108
|
-
.filter((p) => p.slug && p.title);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async function fetchPost(cfg: SeoBlogConfig, slug: string, locale: string): Promise<SeoBlogPostFull | null> {
|
|
112
|
-
const m = cfg.mapping;
|
|
113
|
-
if (!m.id) return null;
|
|
114
|
-
const suf = locale ? `_${locale}` : "";
|
|
115
|
-
let url = `${base(cfg)}/rest/v1/${encodeURIComponent(cfg.table)}?select=*&${m.id}=eq.${encodeURIComponent(slug)}`;
|
|
116
|
-
if (m.published) url += `&${m.published}=eq.true`;
|
|
117
|
-
url += `&limit=1`;
|
|
118
|
-
const rows = await supaGet(cfg, url);
|
|
119
|
-
const r = rows && rows[0];
|
|
120
|
-
if (!r) return null;
|
|
121
|
-
// Локаль-колонка пуста/отсутствует → берём базовую (оригинал). Так статья не выходит пустой, если
|
|
122
|
-
// перевода на запрошенную локаль нет (доставка тоже кладёт оригинал для непереведённых локалей).
|
|
123
|
-
const val = (colBase: string) => (suf ? S(r[colBase + suf]) : "") || S(r[colBase]);
|
|
124
|
-
const rawBody = val(m.body);
|
|
125
|
-
return {
|
|
126
|
-
slug: S(r[m.id]) || slug,
|
|
127
|
-
title: val(m.title),
|
|
128
|
-
excerpt: m.excerpt ? val(m.excerpt) : "",
|
|
129
|
-
cover: m.image ? (r[m.image] as string | null) ?? null : null,
|
|
130
|
-
date: m.publishedAt ? (r[m.publishedAt] as string | null) ?? null : null,
|
|
131
|
-
bodyHtml: cfg.format === "minimarkup" ? miniMarkupToHtml(rawBody) : sanitizeDeliveryHtml(rawBody),
|
|
132
|
-
theme: cfg.theme, // тему тащим с постом — чтобы SeoBlogArticle применил её без отдельного запроса
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// ── Публичные data-функции (для generateMetadata / generateStaticParams) ──
|
|
26
|
+
// ── Публичные data-функции (для generateMetadata / generateStaticParams) — читают файлы content/blog. ──
|
|
137
27
|
export async function seoBlogList(opts?: { locale?: string; limit?: number }): Promise<SeoBlogSummary[]> {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
return fetchPosts(cfg, opts?.locale ?? firstLocale(cfg), opts?.limit ?? 100);
|
|
28
|
+
const m = await blogfs();
|
|
29
|
+
return m.fsBlogList(opts?.locale ?? m.fsDefaultLocale(), opts?.limit ?? 1000);
|
|
141
30
|
}
|
|
142
31
|
export async function seoBlogPost(slug: string, opts?: { locale?: string }): Promise<SeoBlogPostFull | null> {
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
return fetchPost(cfg, slug, opts?.locale ?? firstLocale(cfg));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ── Мини-разметка (## / - / **) → HTML (для legacy-формата; текст экранируем). ──
|
|
149
|
-
function esc(s: string): string {
|
|
150
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
151
|
-
}
|
|
152
|
-
function inlineMini(s: string): string {
|
|
153
|
-
return esc(s).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
154
|
-
}
|
|
155
|
-
function miniMarkupToHtml(src: string): string {
|
|
156
|
-
const out: string[] = [];
|
|
157
|
-
for (const block of (src || "").split(/\n{2,}/)) {
|
|
158
|
-
const b = block.trim();
|
|
159
|
-
if (!b) continue;
|
|
160
|
-
const lines = b.split("\n");
|
|
161
|
-
if (lines.every((l) => l.startsWith("- "))) {
|
|
162
|
-
out.push("<ul>" + lines.map((l) => `<li>${inlineMini(l.slice(2))}</li>`).join("") + "</ul>");
|
|
163
|
-
} else if (b.startsWith("## ")) {
|
|
164
|
-
out.push(`<h2>${inlineMini(b.slice(3))}</h2>`);
|
|
165
|
-
} else {
|
|
166
|
-
out.push(`<p>${inlineMini(b)}</p>`);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return out.join("");
|
|
32
|
+
const m = await blogfs();
|
|
33
|
+
return m.fsBlogPost(slug, opts?.locale ?? m.fsDefaultLocale());
|
|
170
34
|
}
|
|
171
35
|
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
"h1", "h2", "h3", "h4", "h5", "h6", "p", "br", "hr", "strong", "b", "em", "i", "u", "s",
|
|
177
|
-
"ul", "ol", "li", "blockquote", "a", "img", "figure", "figcaption",
|
|
178
|
-
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "code", "pre", "span", "div",
|
|
179
|
-
]);
|
|
180
|
-
const SANITIZE_ALLOWED_ATTRS = /^(href|src|alt|title|target|rel|width|height|loading|colspan|rowspan|scope|start|type)$/;
|
|
181
|
-
const SANITIZE_VOID = new Set(["br", "hr", "img"]);
|
|
182
|
-
function decodeSchemeEntities(v: string): string {
|
|
183
|
-
return v
|
|
184
|
-
.replace(/&#x([0-9a-fA-F]+);?/g, (_m, h) => { try { return String.fromCodePoint(parseInt(h, 16)); } catch { return ""; } })
|
|
185
|
-
.replace(/&#(\d+);?/g, (_m, d) => { try { return String.fromCodePoint(parseInt(d, 10)); } catch { return ""; } })
|
|
186
|
-
.replace(/&(colon|tab|newline|lf|cr|sol|amp);?/gi, (m) => {
|
|
187
|
-
const k = m.replace(/[&;]/g, "").toLowerCase();
|
|
188
|
-
return ({ colon: ":", sol: "/", amp: "&", tab: " ", newline: " ", lf: " ", cr: " " } as Record<string, string>)[k] ?? " ";
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
function safeUrl(val: string): boolean {
|
|
192
|
-
const v = decodeSchemeEntities(val).toLowerCase().replace(/\s/g, "");
|
|
193
|
-
if (!/^[a-z][a-z0-9+.-]*:/.test(v)) return true;
|
|
194
|
-
return /^(https?:|mailto:|tel:)/.test(v);
|
|
195
|
-
}
|
|
196
|
-
function sanitizeAttrs(tag: string, attrsRaw: string): string {
|
|
197
|
-
const out: string[] = [];
|
|
198
|
-
const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
|
|
199
|
-
let m: RegExpExecArray | null;
|
|
200
|
-
let href = "";
|
|
201
|
-
while ((m = re.exec(attrsRaw))) {
|
|
202
|
-
const name = m[1].toLowerCase();
|
|
203
|
-
const val = m[3] ?? m[4] ?? m[5] ?? "";
|
|
204
|
-
if (name.startsWith("on") || name === "style" || name === "srcset") continue;
|
|
205
|
-
if ((name === "href" || name === "src") && !safeUrl(val)) continue;
|
|
206
|
-
if (!SANITIZE_ALLOWED_ATTRS.test(name)) continue;
|
|
207
|
-
if (name === "href") href = val;
|
|
208
|
-
out.push(`${name}="${val.replace(/"/g, """)}"`);
|
|
209
|
-
}
|
|
210
|
-
if (tag === "a" && !out.some((a) => a.startsWith("rel="))) {
|
|
211
|
-
if (/^(https?:)?\/\//i.test(href.trim())) out.push('rel="noopener nofollow"');
|
|
212
|
-
}
|
|
213
|
-
return out.length ? " " + out.join(" ") : "";
|
|
214
|
-
}
|
|
215
|
-
function sanitizeDeliveryHtml(html: string): string {
|
|
216
|
-
let s = html || "";
|
|
217
|
-
s = s.replace(/<(script|style|iframe|object|embed|form|noscript|template|svg|math)\b[\s\S]*?<\/\1\s*>/gi, "");
|
|
218
|
-
s = s.replace(/<!--[\s\S]*?-->/g, "");
|
|
219
|
-
s = s.replace(/<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b((?:"[^"]*"|'[^']*'|[^>])*)>/g, (_full, slash, tag, attrs) => {
|
|
220
|
-
const t = tag.toLowerCase();
|
|
221
|
-
if (!SANITIZE_ALLOWED_TAGS.has(t)) return "";
|
|
222
|
-
if (slash) return `</${t}>`;
|
|
223
|
-
return `<${t}${sanitizeAttrs(t, attrs)}${SANITIZE_VOID.has(t) ? " /" : ""}>`;
|
|
224
|
-
});
|
|
225
|
-
return s.trim();
|
|
36
|
+
// Параметры для generateStaticParams роута /blog/[slug]: пре-рендер ВСЕХ статей на сборке (SSG из файлов).
|
|
37
|
+
export async function seoBlogStaticParams(opts?: { locale?: string }): Promise<{ slug: string }[]> {
|
|
38
|
+
const list = await seoBlogList({ locale: opts?.locale, limit: 100000 });
|
|
39
|
+
return list.map((p) => ({ slug: p.slug }));
|
|
226
40
|
}
|
|
227
41
|
|
|
228
42
|
// ── Компоненты (серверные) ──────────────────────────────────────────────────────────────────
|
|
229
43
|
// Цвета/рамки/скругления — через СОБСТВЕННЫЕ переменные пакета (--sa-*) с нейтральным дефолтом.
|
|
230
44
|
// НЕ ссылаемся на токены сайта напрямую: shadcn/Tailwind хранят их как сырые HSL-компоненты
|
|
231
45
|
// (--primary: 222 47% 11%), в color/border это невалидно и ломает вид. Реальные цвета сайта
|
|
232
|
-
// проставит платформа:
|
|
46
|
+
// проставит платформа: .blogfs.json несёт тему, а хост-обёртка выставит --sa-* валидными цветами.
|
|
233
47
|
// Шрифт и базовый цвет текста наследуются от layout сайта (color:inherit, без font-family).
|
|
234
48
|
const BODY_CSS = `.seoagent-blog-body{color:inherit}
|
|
235
49
|
.seoagent-blog-body img{max-width:100%;height:auto;border-radius:var(--sa-radius,8px);margin:1rem 0}
|
|
@@ -278,47 +92,38 @@ const GRID_CSS = `.sa-blog-wrap{max-width:1120px;margin:0 auto;padding:var(--sa-
|
|
|
278
92
|
.sa-blog-empty{color:var(--sa-muted,#64748b)}`;
|
|
279
93
|
|
|
280
94
|
const BLOG_TITLE: Record<string, string> = { ru: "Блог", uz: "Blog", en: "Blog", ar: "المدونة", tr: "Blog" };
|
|
281
|
-
const MORE_LABEL: Record<string, string> = { ru: "Показать больше", uz: "Ko‘proq ko‘rsatish", en: "Show more", ar: "عرض المزيد" };
|
|
282
95
|
const EMPTY_LABEL: Record<string, string> = { ru: "Пока нет статей.", uz: "Hozircha maqolalar yo‘q.", en: "No posts yet.", ar: "لا توجد مقالات بعد." };
|
|
283
96
|
const L = (map: Record<string, string>, locale: string, fb: string) => map[locale] || map[locale?.split("-")[0]] || fb;
|
|
284
97
|
|
|
285
|
-
// Список статей блога: H1 + карточки (по 4 в ряд на десктопе)
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
98
|
+
// Список статей блога: H1 + карточки (по 4 в ряд на десктопе). Всегда рендерит H1 (даже пустой блог).
|
|
99
|
+
// СТАТИЧЕСКИЙ (git-native): НЕ читает searchParams — иначе роут стал бы динамическим, а в рантайме Vercel
|
|
100
|
+
// файлов content/blog в лямбде нет → список был бы пуст. Читаем ВСЕ статьи на сборке; контент меняется
|
|
101
|
+
// git-коммитом → редеплой пересобирает страницу. limit — предохранитель (дефолт 1000).
|
|
102
|
+
// show/perPage приняты для обратной совместимости со старым скаффолдом (?show=), но ИГНОРИРУЮТСЯ.
|
|
103
|
+
export async function SeoBlogIndex(props: { locale?: string; basePath?: string; title?: string; limit?: number; show?: number; perPage?: number }) {
|
|
104
|
+
const m = await blogfs();
|
|
105
|
+
const locale = props.locale ?? m.fsDefaultLocale();
|
|
291
106
|
const title = props.title ?? L(BLOG_TITLE, locale, "Blog");
|
|
292
|
-
const per = Math.max(1, Math.floor(props.perPage ?? 20));
|
|
293
|
-
const show = Math.max(per, Math.floor(Number(props.show) || per));
|
|
294
107
|
const basePath = (props.basePath ?? "/blog").replace(/\/+$/, "");
|
|
295
|
-
|
|
296
|
-
const posts =
|
|
297
|
-
const
|
|
298
|
-
const visible = posts.slice(0, show);
|
|
108
|
+
const limit = Math.max(1, Math.floor(props.limit ?? 1000));
|
|
109
|
+
const posts = m.fsBlogList(locale, limit);
|
|
110
|
+
const theme = m.fsTheme(); // тема из .blogfs.json
|
|
299
111
|
return (
|
|
300
|
-
<div className="sa-blog-wrap" style={themeStyle(
|
|
112
|
+
<div className="sa-blog-wrap" style={themeStyle(theme) as CSSProperties}>
|
|
301
113
|
<style dangerouslySetInnerHTML={{ __html: GRID_CSS }} />
|
|
302
114
|
<h1 className="sa-blog-h1">{title}</h1>
|
|
303
|
-
{
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
{
|
|
307
|
-
<
|
|
308
|
-
|
|
309
|
-
<
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
</div>
|
|
316
|
-
{hasMore ? (
|
|
317
|
-
<div className="sa-blog-more">
|
|
318
|
-
<a href={`${basePath}?show=${show + per}`} rel="nofollow">{L(MORE_LABEL, locale, "Show more")}</a>
|
|
319
|
-
</div>
|
|
320
|
-
) : null}
|
|
321
|
-
</>
|
|
115
|
+
{posts.length ? (
|
|
116
|
+
<div className="sa-blog-grid">
|
|
117
|
+
{posts.map((p) => (
|
|
118
|
+
<a key={p.slug} className="sa-blog-card" href={`${basePath}/${p.slug}`}>
|
|
119
|
+
{p.cover ? <img src={p.cover} alt={p.title} loading="lazy" /> : null}
|
|
120
|
+
<div className="sa-cb">
|
|
121
|
+
<h2>{p.title}</h2>
|
|
122
|
+
{p.excerpt ? <p>{p.excerpt}</p> : null}
|
|
123
|
+
</div>
|
|
124
|
+
</a>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
322
127
|
) : (
|
|
323
128
|
<p className="sa-blog-empty">{L(EMPTY_LABEL, locale, "No posts yet.")}</p>
|
|
324
129
|
)}
|
|
@@ -334,7 +139,7 @@ export function SeoBlogArticle({ post, locale }: { post: SeoBlogPostFull; locale
|
|
|
334
139
|
{post.cover ? <img src={post.cover} alt={post.title} style={{ width: "100%", borderRadius: "var(--sa-radius, 12px)", marginBottom: "1.5rem" }} /> : null}
|
|
335
140
|
<h1 style={{ fontSize: "2.2rem", lineHeight: 1.2, margin: "0 0 1rem" }}>{post.title}</h1>
|
|
336
141
|
{post.date ? <p style={{ color: "var(--sa-muted, #94a3b8)", margin: "0 0 1.5rem" }}>{fmtDate(post.date, locale)}</p> : null}
|
|
337
|
-
{/* Тело уже санитайзено при доставке
|
|
142
|
+
{/* Тело уже санитайзено при доставке — рендерим как HTML. */}
|
|
338
143
|
<div className="seoagent-blog-body" dangerouslySetInnerHTML={{ __html: post.bodyHtml }} />
|
|
339
144
|
</article>
|
|
340
145
|
);
|