@webnumseoagent/next 0.6.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 +15 -14
- package/blog-fs.ts +42 -3
- package/blog.tsx +45 -210
- package/dist/blog-fs.d.ts +6 -1
- package/dist/blog-fs.js +47 -1
- package/dist/blog.d.ts +1 -18
- package/dist/blog.js +20 -185
- package/dist/sitemap-build.js +49 -4
- package/package.json +1 -1
- 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) {
|
|
@@ -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, seoBlogStaticParams, 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, seoBlogStaticParams, 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
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { sanitizeDeliveryHtml } from "./sanitize";
|
|
8
|
-
import type { SeoBlogSummary, SeoBlogPostFull } from "./blog";
|
|
8
|
+
import type { SeoBlogSummary, SeoBlogPostFull, SeoBlogTheme } from "./blog";
|
|
9
9
|
|
|
10
10
|
const BLOG_DIR = process.env.SEOAGENT_BLOG_DIR ?? "content/blog";
|
|
11
11
|
const SUPPORTED_MAJOR = 1; // schema "seoagent.blog/v1"
|
|
@@ -32,10 +32,29 @@ function schemaMajor(schema: unknown): number | null {
|
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function readDescriptor(): { defaultLocale?: string; locales?: string[]; schema?: string } | null {
|
|
35
|
+
function readDescriptor(): { defaultLocale?: string; locales?: string[]; schema?: string; urlPrefix?: string; theme?: SeoBlogTheme } | null {
|
|
36
36
|
try { return JSON.parse(readFileSync(join(root(), ".blogfs.json"), "utf8")); } catch { return null; }
|
|
37
37
|
}
|
|
38
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
|
+
|
|
39
58
|
// Есть ли файловый блог в репо (наличие .blogfs.json = git-сайт с ≥1 доставкой). Сигнал авто-детекта.
|
|
40
59
|
export function fsAvailable(): boolean {
|
|
41
60
|
return readDescriptor() !== null;
|
|
@@ -115,7 +134,7 @@ export function fsBlogPost(slug: string, locale: string): SeoBlogPostFull | null
|
|
|
115
134
|
...s,
|
|
116
135
|
// Тело уже санитайзено при доставке; чистим повторно (защита-в-глубину). bodyFormat="mdx" появится позже.
|
|
117
136
|
bodyHtml: sanitizeDeliveryHtml(bodyRaw),
|
|
118
|
-
theme:
|
|
137
|
+
theme: fsTheme(), // тема из .blogfs.json (Фаза 3) — фирменные цвета блога без запроса к платформе
|
|
119
138
|
};
|
|
120
139
|
}
|
|
121
140
|
|
|
@@ -123,3 +142,23 @@ export function fsBlogPost(slug: string, locale: string): SeoBlogPostFull | null
|
|
|
123
142
|
export function fsDirExists(): boolean {
|
|
124
143
|
try { return existsSync(root()); } catch { return false; }
|
|
125
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,204 +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
|
-
import { sanitizeDeliveryHtml } from "./sanitize";
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
|
|
23
|
-
const REVALIDATE = 300;
|
|
24
|
-
// Источник блога: "git"/"fs" — читать файлы content/blog из репо (Фаза 4); "supabase" — REST API сайта;
|
|
25
|
-
// не задан → авто (файлы, если в репо есть .blogfs.json, иначе supabase). Файловый источник грузим
|
|
26
|
-
// динамически (node:fs только когда он реально нужен — supabase-сайты его не тянут).
|
|
27
|
-
const BLOG_SOURCE = (process.env.SEOAGENT_BLOG_SOURCE ?? "").toLowerCase();
|
|
28
|
-
async function fsActive(): Promise<typeof import("./blog-fs") | null> {
|
|
29
|
-
if (BLOG_SOURCE === "supabase") return null;
|
|
30
|
-
try {
|
|
31
|
-
const m = await import("./blog-fs");
|
|
32
|
-
if (BLOG_SOURCE === "git" || BLOG_SOURCE === "fs" || m.fsAvailable()) return m;
|
|
33
|
-
} catch { /* нет модуля/fs недоступен → supabase */ }
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
17
|
+
// Файловый ридер (node:fs) грузим лениво — только когда блог реально рендерится (на сборке).
|
|
18
|
+
const blogfs = () => import("./blog-fs");
|
|
36
19
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
id?: string; image?: string; published?: string; publishedAt?: string;
|
|
40
|
-
}
|
|
41
|
-
// Тема блога от платформы (config-API): цвета/скругления как ВАЛИДНЫЕ CSS-значения, которые пакет
|
|
42
|
-
// кладёт в свои переменные --sa-*. Сейчас платформа шлёт только accent (бренд-цвет владельца);
|
|
43
|
-
// border/surface/muted/radius зарезервированы под будущий ИИ-профиль дизайна сайта.
|
|
20
|
+
// Тема блога: цвета/скругления как ВАЛИДНЫЕ CSS-значения → пакет кладёт их в свои --sa-*. Приходит из
|
|
21
|
+
// .blogfs.json (платформа пишет бренд-цвет владельца в accent + опц. токены оформления сайта).
|
|
44
22
|
export interface SeoBlogTheme { accent?: string; border?: string; muted?: string; surface?: string; thBg?: string; radius?: string }
|
|
45
|
-
export interface SeoBlogConfig {
|
|
46
|
-
supabaseUrl: string; anonKey: string; table: string;
|
|
47
|
-
format: "html" | "minimarkup"; locales: string[]; mapping: SeoBlogMapping; theme?: SeoBlogTheme;
|
|
48
|
-
}
|
|
49
23
|
export interface SeoBlogSummary { slug: string; title: string; excerpt: string; cover: string | null; date: string | null }
|
|
50
24
|
export interface SeoBlogPostFull extends SeoBlogSummary { bodyHtml: string; theme?: SeoBlogTheme }
|
|
51
25
|
|
|
52
|
-
|
|
53
|
-
return (cfg.locales ?? []).filter(Boolean)[0] ?? "";
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Публичный конфиг блога с платформы (кэш ISR + тег seo-<site> — сбрасывается ревалидацией).
|
|
57
|
-
async function fetchBlogConfig(): Promise<SeoBlogConfig | null> {
|
|
58
|
-
if (!API || !SITE || !TOKEN) return null;
|
|
59
|
-
try {
|
|
60
|
-
const r = await fetch(`${API}/api/seo/${SITE}/blog`, {
|
|
61
|
-
headers: { "x-seo-agent-key": TOKEN },
|
|
62
|
-
next: { revalidate: REVALIDATE, tags: [`seo-${SITE}`, `seo-blog-${SITE}`] },
|
|
63
|
-
signal: AbortSignal.timeout(5000),
|
|
64
|
-
});
|
|
65
|
-
if (!r.ok) return null;
|
|
66
|
-
const d = (await r.json()) as { blog?: SeoBlogConfig | null };
|
|
67
|
-
const b = d?.blog ?? null;
|
|
68
|
-
if (!b?.supabaseUrl || !b?.anonKey || !b?.table || !b?.mapping?.title || !b?.mapping?.body) return null;
|
|
69
|
-
return b;
|
|
70
|
-
} catch {
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Запрос к REST API Supabase клиента (anon). Возвращает массив строк либо null.
|
|
76
|
-
async function supaGet(cfg: SeoBlogConfig, url: string): Promise<Record<string, unknown>[] | null> {
|
|
77
|
-
try {
|
|
78
|
-
const r = await fetch(url, {
|
|
79
|
-
headers: { apikey: cfg.anonKey, Authorization: `Bearer ${cfg.anonKey}` },
|
|
80
|
-
next: { revalidate: REVALIDATE, tags: [`seo-blog-${SITE}`] },
|
|
81
|
-
signal: AbortSignal.timeout(6000),
|
|
82
|
-
});
|
|
83
|
-
if (!r.ok) return null;
|
|
84
|
-
const d = await r.json();
|
|
85
|
-
return Array.isArray(d) ? d : null;
|
|
86
|
-
} catch {
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const S = (v: unknown): string => (v == null ? "" : String(v));
|
|
92
|
-
const base = (cfg: SeoBlogConfig): string => cfg.supabaseUrl.replace(/\/+$/, "");
|
|
93
|
-
|
|
94
|
-
async function fetchPosts(cfg: SeoBlogConfig, locale: string, limit: number): Promise<SeoBlogSummary[]> {
|
|
95
|
-
const m = cfg.mapping;
|
|
96
|
-
if (!m.id) return []; // без колонки id/slug ссылки на статьи не построить
|
|
97
|
-
const buildUrl = (suf: string) => {
|
|
98
|
-
const titleCol = m.title + suf;
|
|
99
|
-
const excerptCol = m.excerpt ? m.excerpt + suf : "";
|
|
100
|
-
const cols = [m.id, titleCol, excerptCol, m.image || "", m.publishedAt || ""].filter(Boolean).join(",");
|
|
101
|
-
let url = `${base(cfg)}/rest/v1/${encodeURIComponent(cfg.table)}?select=${cols}`;
|
|
102
|
-
if (m.published) url += `&${m.published}=eq.true`;
|
|
103
|
-
if (m.publishedAt) url += `&order=${m.publishedAt}.desc`;
|
|
104
|
-
url += `&limit=${limit}`;
|
|
105
|
-
return { url, titleCol, excerptCol };
|
|
106
|
-
};
|
|
107
|
-
const suf = locale ? `_${locale}` : "";
|
|
108
|
-
let q = buildUrl(suf);
|
|
109
|
-
let rows = await supaGet(cfg, q.url);
|
|
110
|
-
// Запрошенной локали нет среди колонок доставки (напр. языки блога не совпали с локалями сайта) →
|
|
111
|
-
// PostgREST вернул 400 (supaGet=null). Откатываемся на базовые колонки (оригинал), а не показываем пусто.
|
|
112
|
-
if (rows === null && suf) { q = buildUrl(""); rows = await supaGet(cfg, q.url); }
|
|
113
|
-
return (rows ?? [])
|
|
114
|
-
.map((r) => ({
|
|
115
|
-
slug: S(r[m.id!]),
|
|
116
|
-
title: S(r[q.titleCol]),
|
|
117
|
-
excerpt: q.excerptCol ? S(r[q.excerptCol]) : "",
|
|
118
|
-
cover: m.image ? (r[m.image] as string | null) ?? null : null,
|
|
119
|
-
date: m.publishedAt ? (r[m.publishedAt] as string | null) ?? null : null,
|
|
120
|
-
}))
|
|
121
|
-
.filter((p) => p.slug && p.title);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async function fetchPost(cfg: SeoBlogConfig, slug: string, locale: string): Promise<SeoBlogPostFull | null> {
|
|
125
|
-
const m = cfg.mapping;
|
|
126
|
-
if (!m.id) return null;
|
|
127
|
-
const suf = locale ? `_${locale}` : "";
|
|
128
|
-
let url = `${base(cfg)}/rest/v1/${encodeURIComponent(cfg.table)}?select=*&${m.id}=eq.${encodeURIComponent(slug)}`;
|
|
129
|
-
if (m.published) url += `&${m.published}=eq.true`;
|
|
130
|
-
url += `&limit=1`;
|
|
131
|
-
const rows = await supaGet(cfg, url);
|
|
132
|
-
const r = rows && rows[0];
|
|
133
|
-
if (!r) return null;
|
|
134
|
-
// Локаль-колонка пуста/отсутствует → берём базовую (оригинал). Так статья не выходит пустой, если
|
|
135
|
-
// перевода на запрошенную локаль нет (доставка тоже кладёт оригинал для непереведённых локалей).
|
|
136
|
-
const val = (colBase: string) => (suf ? S(r[colBase + suf]) : "") || S(r[colBase]);
|
|
137
|
-
const rawBody = val(m.body);
|
|
138
|
-
return {
|
|
139
|
-
slug: S(r[m.id]) || slug,
|
|
140
|
-
title: val(m.title),
|
|
141
|
-
excerpt: m.excerpt ? val(m.excerpt) : "",
|
|
142
|
-
cover: m.image ? (r[m.image] as string | null) ?? null : null,
|
|
143
|
-
date: m.publishedAt ? (r[m.publishedAt] as string | null) ?? null : null,
|
|
144
|
-
bodyHtml: cfg.format === "minimarkup" ? miniMarkupToHtml(rawBody) : sanitizeDeliveryHtml(rawBody),
|
|
145
|
-
theme: cfg.theme, // тему тащим с постом — чтобы SeoBlogArticle применил её без отдельного запроса
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// ── Публичные data-функции (для generateMetadata / generateStaticParams) ──
|
|
150
|
-
// Роутинг источника: файловый (репо) при наличии, иначе supabase (REST API сайта).
|
|
26
|
+
// ── Публичные data-функции (для generateMetadata / generateStaticParams) — читают файлы content/blog. ──
|
|
151
27
|
export async function seoBlogList(opts?: { locale?: string; limit?: number }): Promise<SeoBlogSummary[]> {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
const cfg = await fetchBlogConfig();
|
|
155
|
-
if (!cfg) return [];
|
|
156
|
-
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);
|
|
157
30
|
}
|
|
158
31
|
export async function seoBlogPost(slug: string, opts?: { locale?: string }): Promise<SeoBlogPostFull | null> {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
const cfg = await fetchBlogConfig();
|
|
162
|
-
if (!cfg) return null;
|
|
163
|
-
return fetchPost(cfg, slug, opts?.locale ?? firstLocale(cfg));
|
|
32
|
+
const m = await blogfs();
|
|
33
|
+
return m.fsBlogPost(slug, opts?.locale ?? m.fsDefaultLocale());
|
|
164
34
|
}
|
|
165
35
|
|
|
166
|
-
// Параметры для generateStaticParams роута /blog/[slug]: пре-рендер
|
|
167
|
-
// файлового источника; для supabase — пре-рендер известных статей, новые добираются ISR по revalidate).
|
|
36
|
+
// Параметры для generateStaticParams роута /blog/[slug]: пре-рендер ВСЕХ статей на сборке (SSG из файлов).
|
|
168
37
|
export async function seoBlogStaticParams(opts?: { locale?: string }): Promise<{ slug: string }[]> {
|
|
169
38
|
const list = await seoBlogList({ locale: opts?.locale, limit: 100000 });
|
|
170
39
|
return list.map((p) => ({ slug: p.slug }));
|
|
171
40
|
}
|
|
172
41
|
|
|
173
|
-
// ── Мини-разметка (## / - / **) → HTML (для legacy-формата; текст экранируем). ──
|
|
174
|
-
function esc(s: string): string {
|
|
175
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
176
|
-
}
|
|
177
|
-
function inlineMini(s: string): string {
|
|
178
|
-
return esc(s).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
179
|
-
}
|
|
180
|
-
function miniMarkupToHtml(src: string): string {
|
|
181
|
-
const out: string[] = [];
|
|
182
|
-
for (const block of (src || "").split(/\n{2,}/)) {
|
|
183
|
-
const b = block.trim();
|
|
184
|
-
if (!b) continue;
|
|
185
|
-
const lines = b.split("\n");
|
|
186
|
-
if (lines.every((l) => l.startsWith("- "))) {
|
|
187
|
-
out.push("<ul>" + lines.map((l) => `<li>${inlineMini(l.slice(2))}</li>`).join("") + "</ul>");
|
|
188
|
-
} else if (b.startsWith("## ")) {
|
|
189
|
-
out.push(`<h2>${inlineMini(b.slice(3))}</h2>`);
|
|
190
|
-
} else {
|
|
191
|
-
out.push(`<p>${inlineMini(b)}</p>`);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
return out.join("");
|
|
195
|
-
}
|
|
196
|
-
|
|
197
42
|
// ── Компоненты (серверные) ──────────────────────────────────────────────────────────────────
|
|
198
43
|
// Цвета/рамки/скругления — через СОБСТВЕННЫЕ переменные пакета (--sa-*) с нейтральным дефолтом.
|
|
199
44
|
// НЕ ссылаемся на токены сайта напрямую: shadcn/Tailwind хранят их как сырые HSL-компоненты
|
|
200
45
|
// (--primary: 222 47% 11%), в color/border это невалидно и ломает вид. Реальные цвета сайта
|
|
201
|
-
// проставит платформа:
|
|
46
|
+
// проставит платформа: .blogfs.json несёт тему, а хост-обёртка выставит --sa-* валидными цветами.
|
|
202
47
|
// Шрифт и базовый цвет текста наследуются от layout сайта (color:inherit, без font-family).
|
|
203
48
|
const BODY_CSS = `.seoagent-blog-body{color:inherit}
|
|
204
49
|
.seoagent-blog-body img{max-width:100%;height:auto;border-radius:var(--sa-radius,8px);margin:1rem 0}
|
|
@@ -247,48 +92,38 @@ const GRID_CSS = `.sa-blog-wrap{max-width:1120px;margin:0 auto;padding:var(--sa-
|
|
|
247
92
|
.sa-blog-empty{color:var(--sa-muted,#64748b)}`;
|
|
248
93
|
|
|
249
94
|
const BLOG_TITLE: Record<string, string> = { ru: "Блог", uz: "Blog", en: "Blog", ar: "المدونة", tr: "Blog" };
|
|
250
|
-
const MORE_LABEL: Record<string, string> = { ru: "Показать больше", uz: "Ko‘proq ko‘rsatish", en: "Show more", ar: "عرض المزيد" };
|
|
251
95
|
const EMPTY_LABEL: Record<string, string> = { ru: "Пока нет статей.", uz: "Hozircha maqolalar yo‘q.", en: "No posts yet.", ar: "لا توجد مقالات بعد." };
|
|
252
96
|
const L = (map: Record<string, string>, locale: string, fb: string) => map[locale] || map[locale?.split("-")[0]] || fb;
|
|
253
97
|
|
|
254
|
-
// Список статей блога: H1 + карточки (по 4 в ряд на десктопе)
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const
|
|
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();
|
|
261
106
|
const title = props.title ?? L(BLOG_TITLE, locale, "Blog");
|
|
262
|
-
const per = Math.max(1, Math.floor(props.perPage ?? 20));
|
|
263
|
-
const show = Math.max(per, Math.floor(Number(props.show) || per));
|
|
264
107
|
const basePath = (props.basePath ?? "/blog").replace(/\/+$/, "");
|
|
265
|
-
|
|
266
|
-
const posts =
|
|
267
|
-
const
|
|
268
|
-
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
|
|
269
111
|
return (
|
|
270
|
-
<div className="sa-blog-wrap" style={themeStyle(
|
|
112
|
+
<div className="sa-blog-wrap" style={themeStyle(theme) as CSSProperties}>
|
|
271
113
|
<style dangerouslySetInnerHTML={{ __html: GRID_CSS }} />
|
|
272
114
|
<h1 className="sa-blog-h1">{title}</h1>
|
|
273
|
-
{
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
{
|
|
277
|
-
<
|
|
278
|
-
|
|
279
|
-
<
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
</div>
|
|
286
|
-
{hasMore ? (
|
|
287
|
-
<div className="sa-blog-more">
|
|
288
|
-
<a href={`${basePath}?show=${show + per}`} rel="nofollow">{L(MORE_LABEL, locale, "Show more")}</a>
|
|
289
|
-
</div>
|
|
290
|
-
) : null}
|
|
291
|
-
</>
|
|
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>
|
|
292
127
|
) : (
|
|
293
128
|
<p className="sa-blog-empty">{L(EMPTY_LABEL, locale, "No posts yet.")}</p>
|
|
294
129
|
)}
|
|
@@ -304,7 +139,7 @@ export function SeoBlogArticle({ post, locale }: { post: SeoBlogPostFull; locale
|
|
|
304
139
|
{post.cover ? <img src={post.cover} alt={post.title} style={{ width: "100%", borderRadius: "var(--sa-radius, 12px)", marginBottom: "1.5rem" }} /> : null}
|
|
305
140
|
<h1 style={{ fontSize: "2.2rem", lineHeight: 1.2, margin: "0 0 1rem" }}>{post.title}</h1>
|
|
306
141
|
{post.date ? <p style={{ color: "var(--sa-muted, #94a3b8)", margin: "0 0 1.5rem" }}>{fmtDate(post.date, locale)}</p> : null}
|
|
307
|
-
{/* Тело уже санитайзено при доставке
|
|
142
|
+
{/* Тело уже санитайзено при доставке — рендерим как HTML. */}
|
|
308
143
|
<div className="seoagent-blog-body" dangerouslySetInnerHTML={{ __html: post.bodyHtml }} />
|
|
309
144
|
</article>
|
|
310
145
|
);
|
package/dist/blog-fs.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import type { SeoBlogSummary, SeoBlogPostFull } from "./blog";
|
|
1
|
+
import type { SeoBlogSummary, SeoBlogPostFull, SeoBlogTheme } from "./blog";
|
|
2
|
+
export declare function fsTheme(): SeoBlogTheme | undefined;
|
|
2
3
|
export declare function fsAvailable(): boolean;
|
|
3
4
|
export declare function fsDefaultLocale(): string;
|
|
4
5
|
export declare function fsBlogList(locale: string, limit: number): SeoBlogSummary[];
|
|
5
6
|
export declare function fsBlogPost(slug: string, locale: string): SeoBlogPostFull | null;
|
|
6
7
|
export declare function fsDirExists(): boolean;
|
|
8
|
+
export declare function fsBlogSitemapEntries(origin: string): {
|
|
9
|
+
loc: string;
|
|
10
|
+
lastmod?: string;
|
|
11
|
+
}[];
|
package/dist/blog-fs.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fsTheme = fsTheme;
|
|
3
4
|
exports.fsAvailable = fsAvailable;
|
|
4
5
|
exports.fsDefaultLocale = fsDefaultLocale;
|
|
5
6
|
exports.fsBlogList = fsBlogList;
|
|
6
7
|
exports.fsBlogPost = fsBlogPost;
|
|
7
8
|
exports.fsDirExists = fsDirExists;
|
|
9
|
+
exports.fsBlogSitemapEntries = fsBlogSitemapEntries;
|
|
8
10
|
// Файловый источник блога (Фаза 4 blogfs): читает статьи из репозитория сайта (папка content/blog,
|
|
9
11
|
// формат blogfs v1 — см. docs/blog-git-delivery/FORMAT.md) на СБОРКЕ, без сети и без Supabase.
|
|
10
12
|
// Отдаёт те же формы, что и supabase-источник (SeoBlogSummary / SeoBlogPostFull) — blog.tsx роутит.
|
|
@@ -41,6 +43,26 @@ function readDescriptor() {
|
|
|
41
43
|
return null;
|
|
42
44
|
}
|
|
43
45
|
}
|
|
46
|
+
// Тема оформления блога из дескриптора (git-режим, Фаза 3): ридер применяет её в --sa-* БЕЗ config-API.
|
|
47
|
+
// Валиден только объект с строковым accent (иначе игнор → дефолтные --sa-*).
|
|
48
|
+
function fsTheme() {
|
|
49
|
+
const t = readDescriptor()?.theme;
|
|
50
|
+
return t && typeof t === "object" && typeof t.accent === "string" && t.accent ? t : undefined;
|
|
51
|
+
}
|
|
52
|
+
// Канонический префикс публичных URL блога. Приоритет — явный descriptor.urlPrefix (пишет платформа);
|
|
53
|
+
// фолбэк для старых дескрипторов без него: <2 локалей → "" (одноязычный /blog), иначе "/<defaultLocale>".
|
|
54
|
+
function urlPrefix() {
|
|
55
|
+
const d = readDescriptor();
|
|
56
|
+
if (!d)
|
|
57
|
+
return "";
|
|
58
|
+
if (typeof d.urlPrefix === "string")
|
|
59
|
+
return d.urlPrefix.replace(/\/+$/, "");
|
|
60
|
+
const locales = Array.isArray(d.locales) ? d.locales.map((l) => String(l).trim()).filter(Boolean) : [];
|
|
61
|
+
if (locales.length < 2)
|
|
62
|
+
return "";
|
|
63
|
+
const dflt = (typeof d.defaultLocale === "string" && d.defaultLocale.trim()) || locales[0];
|
|
64
|
+
return dflt ? `/${dflt}` : "";
|
|
65
|
+
}
|
|
44
66
|
// Есть ли файловый блог в репо (наличие .blogfs.json = git-сайт с ≥1 доставкой). Сигнал авто-детекта.
|
|
45
67
|
function fsAvailable() {
|
|
46
68
|
return readDescriptor() !== null;
|
|
@@ -139,7 +161,7 @@ function fsBlogPost(slug, locale) {
|
|
|
139
161
|
...s,
|
|
140
162
|
// Тело уже санитайзено при доставке; чистим повторно (защита-в-глубину). bodyFormat="mdx" появится позже.
|
|
141
163
|
bodyHtml: (0, sanitize_1.sanitizeDeliveryHtml)(bodyRaw),
|
|
142
|
-
theme:
|
|
164
|
+
theme: fsTheme(), // тема из .blogfs.json (Фаза 3) — фирменные цвета блога без запроса к платформе
|
|
143
165
|
};
|
|
144
166
|
}
|
|
145
167
|
// Есть ли вообще папка блога (для мягких проверок).
|
|
@@ -151,3 +173,27 @@ function fsDirExists() {
|
|
|
151
173
|
return false;
|
|
152
174
|
}
|
|
153
175
|
}
|
|
176
|
+
// Записи блога для sitemap (Фаза 2): страница списка + канонический URL каждой статьи, на СБОРКЕ из файлов.
|
|
177
|
+
// loc = origin + urlPrefix + "/blog[/<slug>]"; lastmod = meta.publishedAt (ISO), если есть. Одна каноническая
|
|
178
|
+
// ссылка на статью (префикс дефолтной локали) — как платформа пингует IndexNow; локали-альтернативы позже (hreflang).
|
|
179
|
+
// Fail-safe: любая ошибка → []. origin — https://домен (без хвостового слэша), берётся из карты сайта.
|
|
180
|
+
function fsBlogSitemapEntries(origin) {
|
|
181
|
+
try {
|
|
182
|
+
const base = (origin || "").replace(/\/+$/, "");
|
|
183
|
+
const pref = urlPrefix();
|
|
184
|
+
const metas = listMetas();
|
|
185
|
+
if (!metas.length)
|
|
186
|
+
return [];
|
|
187
|
+
const out = [{ loc: `${base}${pref}/blog` }];
|
|
188
|
+
for (const { meta } of metas) {
|
|
189
|
+
if (!meta.slug)
|
|
190
|
+
continue;
|
|
191
|
+
const lm = typeof meta.publishedAt === "string" && meta.publishedAt ? meta.publishedAt : undefined;
|
|
192
|
+
out.push({ loc: `${base}${pref}/blog/${meta.slug}`, lastmod: lm });
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
}
|
package/dist/blog.d.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
export interface SeoBlogMapping {
|
|
2
|
-
title: string;
|
|
3
|
-
excerpt?: string;
|
|
4
|
-
body: string;
|
|
5
|
-
id?: string;
|
|
6
|
-
image?: string;
|
|
7
|
-
published?: string;
|
|
8
|
-
publishedAt?: string;
|
|
9
|
-
}
|
|
10
1
|
export interface SeoBlogTheme {
|
|
11
2
|
accent?: string;
|
|
12
3
|
border?: string;
|
|
@@ -15,15 +6,6 @@ export interface SeoBlogTheme {
|
|
|
15
6
|
thBg?: string;
|
|
16
7
|
radius?: string;
|
|
17
8
|
}
|
|
18
|
-
export interface SeoBlogConfig {
|
|
19
|
-
supabaseUrl: string;
|
|
20
|
-
anonKey: string;
|
|
21
|
-
table: string;
|
|
22
|
-
format: "html" | "minimarkup";
|
|
23
|
-
locales: string[];
|
|
24
|
-
mapping: SeoBlogMapping;
|
|
25
|
-
theme?: SeoBlogTheme;
|
|
26
|
-
}
|
|
27
9
|
export interface SeoBlogSummary {
|
|
28
10
|
slug: string;
|
|
29
11
|
title: string;
|
|
@@ -51,6 +33,7 @@ export declare function SeoBlogIndex(props: {
|
|
|
51
33
|
locale?: string;
|
|
52
34
|
basePath?: string;
|
|
53
35
|
title?: string;
|
|
36
|
+
limit?: number;
|
|
54
37
|
show?: number;
|
|
55
38
|
perPage?: number;
|
|
56
39
|
}): Promise<import("react").JSX.Element>;
|
package/dist/blog.js
CHANGED
|
@@ -39,189 +39,27 @@ exports.seoBlogStaticParams = seoBlogStaticParams;
|
|
|
39
39
|
exports.SeoBlogIndex = SeoBlogIndex;
|
|
40
40
|
exports.SeoBlogArticle = SeoBlogArticle;
|
|
41
41
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
|
|
46
|
-
const REVALIDATE = 300;
|
|
47
|
-
// Источник блога: "git"/"fs" — читать файлы content/blog из репо (Фаза 4); "supabase" — REST API сайта;
|
|
48
|
-
// не задан → авто (файлы, если в репо есть .blogfs.json, иначе supabase). Файловый источник грузим
|
|
49
|
-
// динамически (node:fs только когда он реально нужен — supabase-сайты его не тянут).
|
|
50
|
-
const BLOG_SOURCE = (process.env.SEOAGENT_BLOG_SOURCE ?? "").toLowerCase();
|
|
51
|
-
async function fsActive() {
|
|
52
|
-
if (BLOG_SOURCE === "supabase")
|
|
53
|
-
return null;
|
|
54
|
-
try {
|
|
55
|
-
const m = await Promise.resolve().then(() => __importStar(require("./blog-fs")));
|
|
56
|
-
if (BLOG_SOURCE === "git" || BLOG_SOURCE === "fs" || m.fsAvailable())
|
|
57
|
-
return m;
|
|
58
|
-
}
|
|
59
|
-
catch { /* нет модуля/fs недоступен → supabase */ }
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
function firstLocale(cfg) {
|
|
63
|
-
return (cfg.locales ?? []).filter(Boolean)[0] ?? "";
|
|
64
|
-
}
|
|
65
|
-
// Публичный конфиг блога с платформы (кэш ISR + тег seo-<site> — сбрасывается ревалидацией).
|
|
66
|
-
async function fetchBlogConfig() {
|
|
67
|
-
if (!API || !SITE || !TOKEN)
|
|
68
|
-
return null;
|
|
69
|
-
try {
|
|
70
|
-
const r = await fetch(`${API}/api/seo/${SITE}/blog`, {
|
|
71
|
-
headers: { "x-seo-agent-key": TOKEN },
|
|
72
|
-
next: { revalidate: REVALIDATE, tags: [`seo-${SITE}`, `seo-blog-${SITE}`] },
|
|
73
|
-
signal: AbortSignal.timeout(5000),
|
|
74
|
-
});
|
|
75
|
-
if (!r.ok)
|
|
76
|
-
return null;
|
|
77
|
-
const d = (await r.json());
|
|
78
|
-
const b = d?.blog ?? null;
|
|
79
|
-
if (!b?.supabaseUrl || !b?.anonKey || !b?.table || !b?.mapping?.title || !b?.mapping?.body)
|
|
80
|
-
return null;
|
|
81
|
-
return b;
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
// Запрос к REST API Supabase клиента (anon). Возвращает массив строк либо null.
|
|
88
|
-
async function supaGet(cfg, url) {
|
|
89
|
-
try {
|
|
90
|
-
const r = await fetch(url, {
|
|
91
|
-
headers: { apikey: cfg.anonKey, Authorization: `Bearer ${cfg.anonKey}` },
|
|
92
|
-
next: { revalidate: REVALIDATE, tags: [`seo-blog-${SITE}`] },
|
|
93
|
-
signal: AbortSignal.timeout(6000),
|
|
94
|
-
});
|
|
95
|
-
if (!r.ok)
|
|
96
|
-
return null;
|
|
97
|
-
const d = await r.json();
|
|
98
|
-
return Array.isArray(d) ? d : null;
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
const S = (v) => (v == null ? "" : String(v));
|
|
105
|
-
const base = (cfg) => cfg.supabaseUrl.replace(/\/+$/, "");
|
|
106
|
-
async function fetchPosts(cfg, locale, limit) {
|
|
107
|
-
const m = cfg.mapping;
|
|
108
|
-
if (!m.id)
|
|
109
|
-
return []; // без колонки id/slug ссылки на статьи не построить
|
|
110
|
-
const buildUrl = (suf) => {
|
|
111
|
-
const titleCol = m.title + suf;
|
|
112
|
-
const excerptCol = m.excerpt ? m.excerpt + suf : "";
|
|
113
|
-
const cols = [m.id, titleCol, excerptCol, m.image || "", m.publishedAt || ""].filter(Boolean).join(",");
|
|
114
|
-
let url = `${base(cfg)}/rest/v1/${encodeURIComponent(cfg.table)}?select=${cols}`;
|
|
115
|
-
if (m.published)
|
|
116
|
-
url += `&${m.published}=eq.true`;
|
|
117
|
-
if (m.publishedAt)
|
|
118
|
-
url += `&order=${m.publishedAt}.desc`;
|
|
119
|
-
url += `&limit=${limit}`;
|
|
120
|
-
return { url, titleCol, excerptCol };
|
|
121
|
-
};
|
|
122
|
-
const suf = locale ? `_${locale}` : "";
|
|
123
|
-
let q = buildUrl(suf);
|
|
124
|
-
let rows = await supaGet(cfg, q.url);
|
|
125
|
-
// Запрошенной локали нет среди колонок доставки (напр. языки блога не совпали с локалями сайта) →
|
|
126
|
-
// PostgREST вернул 400 (supaGet=null). Откатываемся на базовые колонки (оригинал), а не показываем пусто.
|
|
127
|
-
if (rows === null && suf) {
|
|
128
|
-
q = buildUrl("");
|
|
129
|
-
rows = await supaGet(cfg, q.url);
|
|
130
|
-
}
|
|
131
|
-
return (rows ?? [])
|
|
132
|
-
.map((r) => ({
|
|
133
|
-
slug: S(r[m.id]),
|
|
134
|
-
title: S(r[q.titleCol]),
|
|
135
|
-
excerpt: q.excerptCol ? S(r[q.excerptCol]) : "",
|
|
136
|
-
cover: m.image ? r[m.image] ?? null : null,
|
|
137
|
-
date: m.publishedAt ? r[m.publishedAt] ?? null : null,
|
|
138
|
-
}))
|
|
139
|
-
.filter((p) => p.slug && p.title);
|
|
140
|
-
}
|
|
141
|
-
async function fetchPost(cfg, slug, locale) {
|
|
142
|
-
const m = cfg.mapping;
|
|
143
|
-
if (!m.id)
|
|
144
|
-
return null;
|
|
145
|
-
const suf = locale ? `_${locale}` : "";
|
|
146
|
-
let url = `${base(cfg)}/rest/v1/${encodeURIComponent(cfg.table)}?select=*&${m.id}=eq.${encodeURIComponent(slug)}`;
|
|
147
|
-
if (m.published)
|
|
148
|
-
url += `&${m.published}=eq.true`;
|
|
149
|
-
url += `&limit=1`;
|
|
150
|
-
const rows = await supaGet(cfg, url);
|
|
151
|
-
const r = rows && rows[0];
|
|
152
|
-
if (!r)
|
|
153
|
-
return null;
|
|
154
|
-
// Локаль-колонка пуста/отсутствует → берём базовую (оригинал). Так статья не выходит пустой, если
|
|
155
|
-
// перевода на запрошенную локаль нет (доставка тоже кладёт оригинал для непереведённых локалей).
|
|
156
|
-
const val = (colBase) => (suf ? S(r[colBase + suf]) : "") || S(r[colBase]);
|
|
157
|
-
const rawBody = val(m.body);
|
|
158
|
-
return {
|
|
159
|
-
slug: S(r[m.id]) || slug,
|
|
160
|
-
title: val(m.title),
|
|
161
|
-
excerpt: m.excerpt ? val(m.excerpt) : "",
|
|
162
|
-
cover: m.image ? r[m.image] ?? null : null,
|
|
163
|
-
date: m.publishedAt ? r[m.publishedAt] ?? null : null,
|
|
164
|
-
bodyHtml: cfg.format === "minimarkup" ? miniMarkupToHtml(rawBody) : (0, sanitize_1.sanitizeDeliveryHtml)(rawBody),
|
|
165
|
-
theme: cfg.theme, // тему тащим с постом — чтобы SeoBlogArticle применил её без отдельного запроса
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
// ── Публичные data-функции (для generateMetadata / generateStaticParams) ──
|
|
169
|
-
// Роутинг источника: файловый (репо) при наличии, иначе supabase (REST API сайта).
|
|
42
|
+
// Файловый ридер (node:fs) грузим лениво — только когда блог реально рендерится (на сборке).
|
|
43
|
+
const blogfs = () => Promise.resolve().then(() => __importStar(require("./blog-fs")));
|
|
44
|
+
// ── Публичные data-функции (для generateMetadata / generateStaticParams) — читают файлы content/blog. ──
|
|
170
45
|
async function seoBlogList(opts) {
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
return fs.fsBlogList(opts?.locale ?? fs.fsDefaultLocale(), opts?.limit ?? 100);
|
|
174
|
-
const cfg = await fetchBlogConfig();
|
|
175
|
-
if (!cfg)
|
|
176
|
-
return [];
|
|
177
|
-
return fetchPosts(cfg, opts?.locale ?? firstLocale(cfg), opts?.limit ?? 100);
|
|
46
|
+
const m = await blogfs();
|
|
47
|
+
return m.fsBlogList(opts?.locale ?? m.fsDefaultLocale(), opts?.limit ?? 1000);
|
|
178
48
|
}
|
|
179
49
|
async function seoBlogPost(slug, opts) {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
return fs.fsBlogPost(slug, opts?.locale ?? fs.fsDefaultLocale());
|
|
183
|
-
const cfg = await fetchBlogConfig();
|
|
184
|
-
if (!cfg)
|
|
185
|
-
return null;
|
|
186
|
-
return fetchPost(cfg, slug, opts?.locale ?? firstLocale(cfg));
|
|
50
|
+
const m = await blogfs();
|
|
51
|
+
return m.fsBlogPost(slug, opts?.locale ?? m.fsDefaultLocale());
|
|
187
52
|
}
|
|
188
|
-
// Параметры для generateStaticParams роута /blog/[slug]: пре-рендер
|
|
189
|
-
// файлового источника; для supabase — пре-рендер известных статей, новые добираются ISR по revalidate).
|
|
53
|
+
// Параметры для generateStaticParams роута /blog/[slug]: пре-рендер ВСЕХ статей на сборке (SSG из файлов).
|
|
190
54
|
async function seoBlogStaticParams(opts) {
|
|
191
55
|
const list = await seoBlogList({ locale: opts?.locale, limit: 100000 });
|
|
192
56
|
return list.map((p) => ({ slug: p.slug }));
|
|
193
57
|
}
|
|
194
|
-
// ── Мини-разметка (## / - / **) → HTML (для legacy-формата; текст экранируем). ──
|
|
195
|
-
function esc(s) {
|
|
196
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
197
|
-
}
|
|
198
|
-
function inlineMini(s) {
|
|
199
|
-
return esc(s).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
200
|
-
}
|
|
201
|
-
function miniMarkupToHtml(src) {
|
|
202
|
-
const out = [];
|
|
203
|
-
for (const block of (src || "").split(/\n{2,}/)) {
|
|
204
|
-
const b = block.trim();
|
|
205
|
-
if (!b)
|
|
206
|
-
continue;
|
|
207
|
-
const lines = b.split("\n");
|
|
208
|
-
if (lines.every((l) => l.startsWith("- "))) {
|
|
209
|
-
out.push("<ul>" + lines.map((l) => `<li>${inlineMini(l.slice(2))}</li>`).join("") + "</ul>");
|
|
210
|
-
}
|
|
211
|
-
else if (b.startsWith("## ")) {
|
|
212
|
-
out.push(`<h2>${inlineMini(b.slice(3))}</h2>`);
|
|
213
|
-
}
|
|
214
|
-
else {
|
|
215
|
-
out.push(`<p>${inlineMini(b)}</p>`);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return out.join("");
|
|
219
|
-
}
|
|
220
58
|
// ── Компоненты (серверные) ──────────────────────────────────────────────────────────────────
|
|
221
59
|
// Цвета/рамки/скругления — через СОБСТВЕННЫЕ переменные пакета (--sa-*) с нейтральным дефолтом.
|
|
222
60
|
// НЕ ссылаемся на токены сайта напрямую: shadcn/Tailwind хранят их как сырые HSL-компоненты
|
|
223
61
|
// (--primary: 222 47% 11%), в color/border это невалидно и ломает вид. Реальные цвета сайта
|
|
224
|
-
// проставит платформа:
|
|
62
|
+
// проставит платформа: .blogfs.json несёт тему, а хост-обёртка выставит --sa-* валидными цветами.
|
|
225
63
|
// Шрифт и базовый цвет текста наследуются от layout сайта (color:inherit, без font-family).
|
|
226
64
|
const BODY_CSS = `.seoagent-blog-body{color:inherit}
|
|
227
65
|
.seoagent-blog-body img{max-width:100%;height:auto;border-radius:var(--sa-radius,8px);margin:1rem 0}
|
|
@@ -273,25 +111,22 @@ const GRID_CSS = `.sa-blog-wrap{max-width:1120px;margin:0 auto;padding:var(--sa-
|
|
|
273
111
|
.sa-blog-more a:hover{background:var(--sa-th-bg,#f8fafc)}
|
|
274
112
|
.sa-blog-empty{color:var(--sa-muted,#64748b)}`;
|
|
275
113
|
const BLOG_TITLE = { ru: "Блог", uz: "Blog", en: "Blog", ar: "المدونة", tr: "Blog" };
|
|
276
|
-
const MORE_LABEL = { ru: "Показать больше", uz: "Ko‘proq ko‘rsatish", en: "Show more", ar: "عرض المزيد" };
|
|
277
114
|
const EMPTY_LABEL = { ru: "Пока нет статей.", uz: "Hozircha maqolalar yo‘q.", en: "No posts yet.", ar: "لا توجد مقالات بعد." };
|
|
278
115
|
const L = (map, locale, fb) => map[locale] || map[locale?.split("-")[0]] || fb;
|
|
279
|
-
// Список статей блога: H1 + карточки (по 4 в ряд на десктопе)
|
|
280
|
-
//
|
|
281
|
-
//
|
|
116
|
+
// Список статей блога: H1 + карточки (по 4 в ряд на десктопе). Всегда рендерит H1 (даже пустой блог).
|
|
117
|
+
// СТАТИЧЕСКИЙ (git-native): НЕ читает searchParams — иначе роут стал бы динамическим, а в рантайме Vercel
|
|
118
|
+
// файлов content/blog в лямбде нет → список был бы пуст. Читаем ВСЕ статьи на сборке; контент меняется
|
|
119
|
+
// git-коммитом → редеплой пересобирает страницу. limit — предохранитель (дефолт 1000).
|
|
120
|
+
// show/perPage приняты для обратной совместимости со старым скаффолдом (?show=), но ИГНОРИРУЮТСЯ.
|
|
282
121
|
async function SeoBlogIndex(props) {
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
const locale = props.locale ?? (fs ? fs.fsDefaultLocale() : (cfg ? firstLocale(cfg) : ""));
|
|
122
|
+
const m = await blogfs();
|
|
123
|
+
const locale = props.locale ?? m.fsDefaultLocale();
|
|
286
124
|
const title = props.title ?? L(BLOG_TITLE, locale, "Blog");
|
|
287
|
-
const per = Math.max(1, Math.floor(props.perPage ?? 20));
|
|
288
|
-
const show = Math.max(per, Math.floor(Number(props.show) || per));
|
|
289
125
|
const basePath = (props.basePath ?? "/blog").replace(/\/+$/, "");
|
|
290
|
-
|
|
291
|
-
const posts =
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
return ((0, jsx_runtime_1.jsxs)("div", { className: "sa-blog-wrap", style: themeStyle(cfg?.theme), children: [(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: GRID_CSS } }), (0, jsx_runtime_1.jsx)("h1", { className: "sa-blog-h1", children: title }), visible.length ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("div", { className: "sa-blog-grid", children: visible.map((p) => ((0, jsx_runtime_1.jsxs)("a", { className: "sa-blog-card", href: `${basePath}/${p.slug}`, children: [p.cover ? (0, jsx_runtime_1.jsx)("img", { src: p.cover, alt: p.title, loading: "lazy" }) : null, (0, jsx_runtime_1.jsxs)("div", { className: "sa-cb", children: [(0, jsx_runtime_1.jsx)("h2", { children: p.title }), p.excerpt ? (0, jsx_runtime_1.jsx)("p", { children: p.excerpt }) : null] })] }, p.slug))) }), hasMore ? ((0, jsx_runtime_1.jsx)("div", { className: "sa-blog-more", children: (0, jsx_runtime_1.jsx)("a", { href: `${basePath}?show=${show + per}`, rel: "nofollow", children: L(MORE_LABEL, locale, "Show more") }) })) : null] })) : ((0, jsx_runtime_1.jsx)("p", { className: "sa-blog-empty", children: L(EMPTY_LABEL, locale, "No posts yet.") }))] }));
|
|
126
|
+
const limit = Math.max(1, Math.floor(props.limit ?? 1000));
|
|
127
|
+
const posts = m.fsBlogList(locale, limit);
|
|
128
|
+
const theme = m.fsTheme(); // тема из .blogfs.json
|
|
129
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "sa-blog-wrap", style: themeStyle(theme), children: [(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: GRID_CSS } }), (0, jsx_runtime_1.jsx)("h1", { className: "sa-blog-h1", children: title }), posts.length ? ((0, jsx_runtime_1.jsx)("div", { className: "sa-blog-grid", children: posts.map((p) => ((0, jsx_runtime_1.jsxs)("a", { className: "sa-blog-card", href: `${basePath}/${p.slug}`, children: [p.cover ? (0, jsx_runtime_1.jsx)("img", { src: p.cover, alt: p.title, loading: "lazy" }) : null, (0, jsx_runtime_1.jsxs)("div", { className: "sa-cb", children: [(0, jsx_runtime_1.jsx)("h2", { children: p.title }), p.excerpt ? (0, jsx_runtime_1.jsx)("p", { children: p.excerpt }) : null] })] }, p.slug))) })) : ((0, jsx_runtime_1.jsx)("p", { className: "sa-blog-empty", children: L(EMPTY_LABEL, locale, "No posts yet.") }))] }));
|
|
295
130
|
}
|
|
296
131
|
// Одна статья. Принимает пост (из seoBlogPost) — так страница может сделать notFound()/метаданные.
|
|
297
132
|
function SeoBlogArticle({ post, locale }) {
|
package/dist/sitemap-build.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.seoWrapSitemap = seoWrapSitemap;
|
|
4
37
|
const API = process.env.SEOAGENT_API_BASE ?? "";
|
|
@@ -115,6 +148,8 @@ function groupOfUrl(loc) {
|
|
|
115
148
|
const segs = path.split("/").filter(Boolean);
|
|
116
149
|
return segs.length <= 1 ? "pages" : dec(segs[0]);
|
|
117
150
|
}
|
|
151
|
+
// Раздел записи: явный e.group (напр. блог) важнее эвристики по первому сегменту URL.
|
|
152
|
+
const groupOf = (e) => e.group ?? groupOfUrl(e.loc);
|
|
118
153
|
// Нормализованный путь записи (декодированный, без хвостового слэша) — для сравнения
|
|
119
154
|
// с excludedRoutes (точное совпадение пути).
|
|
120
155
|
function pathOf(loc) {
|
|
@@ -174,6 +209,16 @@ async function seoWrapSitemap(fn, kind, group) {
|
|
|
174
209
|
const origin = originOf(entries);
|
|
175
210
|
if (!origin || entries.length === 0)
|
|
176
211
|
return EMPTY;
|
|
212
|
+
// Блог живёт файлами в репо (git-native) и в генераторе карты сайта его нет — добавляем его записи
|
|
213
|
+
// сами, на сборке из content/blog (раздел карты "blog"). node:fs грузим динамически; нет блога → пропуск.
|
|
214
|
+
try {
|
|
215
|
+
const bf = await Promise.resolve().then(() => __importStar(require("./blog-fs")));
|
|
216
|
+
if (bf.fsAvailable()) {
|
|
217
|
+
for (const b of bf.fsBlogSitemapEntries(origin))
|
|
218
|
+
entries.push({ loc: b.loc, lastmod: b.lastmod, group: "blog" });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch { /* нет модуля/файлов → без блога */ }
|
|
177
222
|
// Отдаём платформе ПОЛНЫЙ список страниц (до исключений) — только на индексном/плоском
|
|
178
223
|
// вызове (у него все URL). Await + троттлинг: доставка надёжна, задержка редка (карта кэш 300с).
|
|
179
224
|
if (kind === "index" || kind === "flat")
|
|
@@ -190,21 +235,21 @@ async function seoWrapSitemap(fn, kind, group) {
|
|
|
190
235
|
const excluded = new Set(cfg.excludedGroups);
|
|
191
236
|
// flat — одна карта со всеми URL (без под-карт); используется, когда под-карты недоступны.
|
|
192
237
|
if (kind === "flat")
|
|
193
|
-
return urlsetXml(origin, entries.filter((e) => !excluded.has(
|
|
238
|
+
return urlsetXml(origin, entries.filter((e) => !excluded.has(groupOf(e))));
|
|
194
239
|
if (kind === "child") {
|
|
195
240
|
const g = (group ?? "").replace(/\.xml$/i, "");
|
|
196
241
|
if (excluded.has(g))
|
|
197
242
|
return EMPTY; // раздел выключен → пустая (валидная) карта
|
|
198
|
-
return urlsetXml(origin, entries.filter((e) =>
|
|
243
|
+
return urlsetXml(origin, entries.filter((e) => groupOf(e) === g));
|
|
199
244
|
}
|
|
200
245
|
// index
|
|
201
|
-
const groups = [...new Set(entries.map((e) =>
|
|
246
|
+
const groups = [...new Set(entries.map((e) => groupOf(e)))].sort().filter((g) => !excluded.has(g));
|
|
202
247
|
if (groups.length === 0)
|
|
203
248
|
return EMPTY; // всё выключено → пустая карта (backstop)
|
|
204
249
|
if (groups.length === 1) {
|
|
205
250
|
// остался один раздел → отдаём его urlset (только его записи, не все!)
|
|
206
251
|
const only = groups[0];
|
|
207
|
-
return urlsetXml(origin, entries.filter((e) =>
|
|
252
|
+
return urlsetXml(origin, entries.filter((e) => groupOf(e) === only));
|
|
208
253
|
}
|
|
209
254
|
return indexXml(origin, groups);
|
|
210
255
|
}
|
package/package.json
CHANGED
package/sitemap-build.ts
CHANGED
|
@@ -55,6 +55,7 @@ interface NEntry {
|
|
|
55
55
|
priority?: number;
|
|
56
56
|
images?: string[];
|
|
57
57
|
title?: string; // имя записи (name/title из генератора) — для списка «SEO страниц» на платформе
|
|
58
|
+
group?: string; // явный раздел карты (напр. "blog") — переопределяет группировку по первому сегменту URL
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
// Авто-репорт списка страниц на платформу: у адаптера есть ВСЕ URL сайта (из генератора
|
|
@@ -144,6 +145,8 @@ function groupOfUrl(loc: string): string {
|
|
|
144
145
|
const segs = path.split("/").filter(Boolean);
|
|
145
146
|
return segs.length <= 1 ? "pages" : dec(segs[0]);
|
|
146
147
|
}
|
|
148
|
+
// Раздел записи: явный e.group (напр. блог) важнее эвристики по первому сегменту URL.
|
|
149
|
+
const groupOf = (e: NEntry): string => e.group ?? groupOfUrl(e.loc);
|
|
147
150
|
|
|
148
151
|
// Нормализованный путь записи (декодированный, без хвостового слэша) — для сравнения
|
|
149
152
|
// с excludedRoutes (точное совпадение пути).
|
|
@@ -207,6 +210,15 @@ export async function seoWrapSitemap(
|
|
|
207
210
|
const origin = originOf(entries);
|
|
208
211
|
if (!origin || entries.length === 0) return EMPTY;
|
|
209
212
|
|
|
213
|
+
// Блог живёт файлами в репо (git-native) и в генераторе карты сайта его нет — добавляем его записи
|
|
214
|
+
// сами, на сборке из content/blog (раздел карты "blog"). node:fs грузим динамически; нет блога → пропуск.
|
|
215
|
+
try {
|
|
216
|
+
const bf = await import("./blog-fs");
|
|
217
|
+
if (bf.fsAvailable()) {
|
|
218
|
+
for (const b of bf.fsBlogSitemapEntries(origin)) entries.push({ loc: b.loc, lastmod: b.lastmod, group: "blog" });
|
|
219
|
+
}
|
|
220
|
+
} catch { /* нет модуля/файлов → без блога */ }
|
|
221
|
+
|
|
210
222
|
// Отдаём платформе ПОЛНЫЙ список страниц (до исключений) — только на индексном/плоском
|
|
211
223
|
// вызове (у него все URL). Await + троттлинг: доставка надёжна, задержка редка (карта кэш 300с).
|
|
212
224
|
if (kind === "index" || kind === "flat") await reportPages(entries);
|
|
@@ -221,21 +233,21 @@ export async function seoWrapSitemap(
|
|
|
221
233
|
const excluded = new Set(cfg.excludedGroups);
|
|
222
234
|
|
|
223
235
|
// flat — одна карта со всеми URL (без под-карт); используется, когда под-карты недоступны.
|
|
224
|
-
if (kind === "flat") return urlsetXml(origin, entries.filter((e) => !excluded.has(
|
|
236
|
+
if (kind === "flat") return urlsetXml(origin, entries.filter((e) => !excluded.has(groupOf(e))));
|
|
225
237
|
|
|
226
238
|
if (kind === "child") {
|
|
227
239
|
const g = (group ?? "").replace(/\.xml$/i, "");
|
|
228
240
|
if (excluded.has(g)) return EMPTY; // раздел выключен → пустая (валидная) карта
|
|
229
|
-
return urlsetXml(origin, entries.filter((e) =>
|
|
241
|
+
return urlsetXml(origin, entries.filter((e) => groupOf(e) === g));
|
|
230
242
|
}
|
|
231
243
|
|
|
232
244
|
// index
|
|
233
|
-
const groups = [...new Set(entries.map((e) =>
|
|
245
|
+
const groups = [...new Set(entries.map((e) => groupOf(e)))].sort().filter((g) => !excluded.has(g));
|
|
234
246
|
if (groups.length === 0) return EMPTY; // всё выключено → пустая карта (backstop)
|
|
235
247
|
if (groups.length === 1) {
|
|
236
248
|
// остался один раздел → отдаём его urlset (только его записи, не все!)
|
|
237
249
|
const only = groups[0];
|
|
238
|
-
return urlsetXml(origin, entries.filter((e) =>
|
|
250
|
+
return urlsetXml(origin, entries.filter((e) => groupOf(e) === only));
|
|
239
251
|
}
|
|
240
252
|
return indexXml(origin, groups);
|
|
241
253
|
}
|