@webnumseoagent/next 0.6.0 → 0.7.1

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 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-компоненты) и содержимое изменилось (напр. старый init-роут без ?show → обновляем на актуальный
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 revalidate = 300;\nexport default function BlogPage() {\n return <SeoBlogIndex title="Блог" />;\n}\n`,
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 revalidate = 300;\n\n// Пре-рендер всех статей на сборке (SSG для файлового источника; для supabase известные статьи, новые добираются ISR).\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`,
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 (пакет-рендер) созданы. Настрой доставку + anon-ключ на платформе. (Отключить блог: \x1b[1m--no-blog\x1b[0m)`);
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; ?show= пагинация по 20.
695
- const idxSig = ext === "ts"
696
- ? (param ? `{ params, searchParams }: { params: { ${param}: string }; searchParams: { show?: string } }` : `{ searchParams }: { searchParams: { show?: string } }`)
697
- : (param ? "{ params, searchParams }" : "{ searchParams }");
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\`} show={Number(searchParams?.show) || 20} />`
700
- : `<SeoBlogIndex show={Number(searchParams?.show) || 20} />`;
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 revalidate = 300;\nexport default function BlogPage(${idxSig}) {\n return ${idxBody};\n}\n`,
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) {
@@ -711,13 +712,16 @@ async function blogPage() {
711
712
  : "{ params }";
712
713
  const localeArg = param ? `, { locale: params.${param} }` : "";
713
714
  const localeProp = param ? ` locale={params.${param}}` : "";
715
+ // Мультиязычный роут /[locale]/blog/[slug]: пре-рендерим КАЖДУЮ статью под КАЖДУЮ локаль (передаём имя
716
+ // сегмента локали) — иначе force-static не покроет комбинацию locale×slug и статьи отдадут 404.
717
+ const gsp = param ? `seoBlogStaticParams({ localeParam: "${param}" })` : `seoBlogStaticParams()`;
714
718
  await writeOurRoute(
715
719
  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 revalidate = 300;\n\n// Пре-рендер статей на сборке (SSG для файлового источника). Слаги не зависят от локали; непокрытые\n// комбинации добираются ISR (dynamicParams по умолчанию true).\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`,
720
+ `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 ${gsp};\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
721
  );
718
722
  }
719
723
 
720
- log(`\n \x1b[32m✓\x1b[0m Готово. Проверь \x1b[36mgit diff\x1b[0m, затем commit + push. Блог: \x1b[1m${localeDir ? `/${param}/blog` : "/blog"}\x1b[0m — дизайн/шапка с сайта, список 4-в-ряд + «Показать больше».\n`);
724
+ log(`\n \x1b[32m✓\x1b[0m Готово. Проверь \x1b[36mgit diff\x1b[0m, затем commit + push. Блог: \x1b[1m${localeDir ? `/${param}/blog` : "/blog"}\x1b[0m — дизайн/шапка с сайта, список 4-в-ряд, статический рендер из файлов.\n`);
721
725
  }
722
726
 
723
727
  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;
@@ -46,6 +65,13 @@ export function fsDefaultLocale(): string {
46
65
  return typeof d === "string" ? d : ""; // битый дескриптор мог дать не-строку → не роняем split ниже
47
66
  }
48
67
 
68
+ // Локали доставки блога из дескриптора (для generateStaticParams мультиязычного роута /[locale]/blog/[slug]:
69
+ // каждую статью надо пре-рендерить под каждую локаль, иначе force-static отдаст 404). Пусто — одноязычный.
70
+ export function fsLocales(): string[] {
71
+ const l = readDescriptor()?.locales;
72
+ return Array.isArray(l) ? l.map((x) => String(x).trim()).filter(Boolean) : [];
73
+ }
74
+
49
75
  // Собрать meta.json всех статей (glob по папкам). Пропускает битые/незнакомые версии/черновики.
50
76
  function listMetas(): { dir: string; meta: Meta }[] {
51
77
  const r = root();
@@ -115,7 +141,7 @@ export function fsBlogPost(slug: string, locale: string): SeoBlogPostFull | null
115
141
  ...s,
116
142
  // Тело уже санитайзено при доставке; чистим повторно (защита-в-глубину). bodyFormat="mdx" появится позже.
117
143
  bodyHtml: sanitizeDeliveryHtml(bodyRaw),
118
- theme: undefined, // файловый источник не несёт тему платформы (используются --sa-* дефолты)
144
+ theme: fsTheme(), // тема из .blogfs.json (Фаза 3) фирменные цвета блога без запроса к платформе
119
145
  };
120
146
  }
121
147
 
@@ -123,3 +149,23 @@ export function fsBlogPost(slug: string, locale: string): SeoBlogPostFull | null
123
149
  export function fsDirExists(): boolean {
124
150
  try { return existsSync(root()); } catch { return false; }
125
151
  }
152
+
153
+ // Записи блога для sitemap (Фаза 2): страница списка + канонический URL каждой статьи, на СБОРКЕ из файлов.
154
+ // loc = origin + urlPrefix + "/blog[/<slug>]"; lastmod = meta.publishedAt (ISO), если есть. Одна каноническая
155
+ // ссылка на статью (префикс дефолтной локали) — как платформа пингует IndexNow; локали-альтернативы позже (hreflang).
156
+ // Fail-safe: любая ошибка → []. origin — https://домен (без хвостового слэша), берётся из карты сайта.
157
+ export function fsBlogSitemapEntries(origin: string): { loc: string; lastmod?: string }[] {
158
+ try {
159
+ const base = (origin || "").replace(/\/+$/, "");
160
+ const pref = urlPrefix();
161
+ const metas = listMetas();
162
+ if (!metas.length) return [];
163
+ const out: { loc: string; lastmod?: string }[] = [{ loc: `${base}${pref}/blog` }];
164
+ for (const { meta } of metas) {
165
+ if (!meta.slug) continue;
166
+ const lm = typeof meta.publishedAt === "string" && meta.publishedAt ? meta.publishedAt : undefined;
167
+ out.push({ loc: `${base}${pref}/blog/${meta.slug}`, lastmod: lm });
168
+ }
169
+ return out;
170
+ } catch { return []; }
171
+ }
package/blog.tsx CHANGED
@@ -1,204 +1,57 @@
1
- // @webnumseoagent/next/blog — рендер блога код-сайта (Фаза 3.1c).
2
- // Пакет тянет ПУБЛИЧНЫЙ конфиг блога с платформы (config-API /blog: supabaseUrl + anonKey +
3
- // таблица/формат/локали/маппинг), затем читает ОПУБЛИКОВАННЫЕ статьи прямо из Supabase клиента
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
- // Тело статьи уже САНИТАЙЗЕНО на стороне платформы при доставке (Фаза 3.1b), поэтому здесь оно
7
- // рендерится как HTML (dangerouslySetInnerHTML) — граница доверия проходит по доставке, не по рендеру.
5
+ // Тело статьи уже САНИТАЙЗЕНО платформой при доставке, поэтому рендерится как HTML (dangerouslySetInnerHTML)
6
+ // граница доверия проходит по доставке, не по рендеру. Ридер санитайзит повторно (защита-в-глубину).
8
7
  //
9
- // Все функции FAIL-SAFE: при ошибке/таймауте возвращают пусто — блог просто не покажет контент,
8
+ // Все функции FAIL-SAFE: при ошибке/отсутствии файлов возвращают пусто — блог не покажет контент,
10
9
  // сборка/рендер сайта НИКОГДА не падает.
11
10
  //
12
- // Использование (роуты ставит `npx @webnumseoagent/next init --blog`):
13
- // app/blog/page.tsx: export default () => <SeoBlogIndex title="Блог" />
14
- // app/blog/[slug]/page.tsx: const post = await seoBlogPost(params.slug); if(!post) notFound();
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
- const API = process.env.SEOAGENT_API_BASE ?? "";
21
- const SITE = process.env.SEOAGENT_SITE_ID ?? "";
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
- export interface SeoBlogMapping {
38
- title: string; excerpt?: string; body: string;
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
- function firstLocale(cfg: SeoBlogConfig): string {
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 fs = await fsActive();
153
- if (fs) return fs.fsBlogList(opts?.locale ?? fs.fsDefaultLocale(), opts?.limit ?? 100);
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 fs = await fsActive();
160
- if (fs) return fs.fsBlogPost(slug, opts?.locale ?? fs.fsDefaultLocale());
161
- const cfg = await fetchBlogConfig();
162
- if (!cfg) return null;
163
- return fetchPost(cfg, slug, opts?.locale ?? firstLocale(cfg));
164
- }
165
-
166
- // Параметры для generateStaticParams роута /blog/[slug]: пре-рендер всех статей на сборке (SSG для
167
- // файлового источника; для supabase — пре-рендер известных статей, новые добираются ISR по revalidate).
168
- export async function seoBlogStaticParams(opts?: { locale?: string }): Promise<{ slug: string }[]> {
169
- const list = await seoBlogList({ locale: opts?.locale, limit: 100000 });
170
- return list.map((p) => ({ slug: p.slug }));
32
+ const m = await blogfs();
33
+ return m.fsBlogPost(slug, opts?.locale ?? m.fsDefaultLocale());
171
34
  }
172
35
 
173
- // ── Мини-разметка (## / - / **) HTML (для legacy-формата; текст экранируем). ──
174
- function esc(s: string): string {
175
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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("");
36
+ // Параметры для generateStaticParams роута блога: пре-рендер ВСЕХ статей на сборке (SSG из файлов).
37
+ // Без localeParam → [{slug}] (одноязычный /blog/[slug]). С localeParam → [{<localeParam>, slug}] для КАЖДОЙ
38
+ // локали (мультиязычный /[locale]/blog/[slug]): иначе force-static не покроет комбинацию locale×slug → 404.
39
+ export async function seoBlogStaticParams(opts?: { localeParam?: string }): Promise<Array<Record<string, string>>> {
40
+ const list = await seoBlogList({ limit: 100000 });
41
+ const slugs = list.map((p) => ({ slug: p.slug }));
42
+ const lp = opts?.localeParam;
43
+ if (!lp) return slugs;
44
+ const m = await blogfs();
45
+ const locales = m.fsLocales();
46
+ if (!locales.length) return slugs;
47
+ return locales.flatMap((loc) => slugs.map((s) => ({ [lp]: loc, slug: s.slug })));
195
48
  }
196
49
 
197
50
  // ── Компоненты (серверные) ──────────────────────────────────────────────────────────────────
198
51
  // Цвета/рамки/скругления — через СОБСТВЕННЫЕ переменные пакета (--sa-*) с нейтральным дефолтом.
199
52
  // НЕ ссылаемся на токены сайта напрямую: shadcn/Tailwind хранят их как сырые HSL-компоненты
200
53
  // (--primary: 222 47% 11%), в color/border это невалидно и ломает вид. Реальные цвета сайта
201
- // проставит платформа: config-API отдаст профиль, а хост-обёртка выставит --sa-* валидными цветами.
54
+ // проставит платформа: .blogfs.json несёт тему, а хост-обёртка выставит --sa-* валидными цветами.
202
55
  // Шрифт и базовый цвет текста наследуются от layout сайта (color:inherit, без font-family).
203
56
  const BODY_CSS = `.seoagent-blog-body{color:inherit}
204
57
  .seoagent-blog-body img{max-width:100%;height:auto;border-radius:var(--sa-radius,8px);margin:1rem 0}
@@ -247,48 +100,38 @@ const GRID_CSS = `.sa-blog-wrap{max-width:1120px;margin:0 auto;padding:var(--sa-
247
100
  .sa-blog-empty{color:var(--sa-muted,#64748b)}`;
248
101
 
249
102
  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
103
  const EMPTY_LABEL: Record<string, string> = { ru: "Пока нет статей.", uz: "Hozircha maqolalar yo‘q.", en: "No posts yet.", ar: "لا توجد مقالات بعد." };
252
104
  const L = (map: Record<string, string>, locale: string, fb: string) => map[locale] || map[locale?.split("-")[0]] || fb;
253
105
 
254
- // Список статей блога: H1 + карточки (по 4 в ряд на десктопе) + «Показать больше» (пагинация по 20
255
- // через ?show=, серверно без клиентского JS, дружелюбно к SEO). Всегда рендерит H1 (даже пустой блог),
256
- // чтобы страница не была пустой. perPage размер «страницы» (дефолт 20), show сколько показать сейчас.
257
- export async function SeoBlogIndex(props: { locale?: string; basePath?: string; title?: string; show?: number; perPage?: number }) {
258
- const fs = await fsActive();
259
- const cfg = fs ? null : await fetchBlogConfig();
260
- const locale = props.locale ?? (fs ? fs.fsDefaultLocale() : (cfg ? firstLocale(cfg) : ""));
106
+ // Список статей блога: H1 + карточки (по 4 в ряд на десктопе). Всегда рендерит H1 (даже пустой блог).
107
+ // СТАТИЧЕСКИЙ (git-native): НЕ читает searchParams иначе роут стал бы динамическим, а в рантайме Vercel
108
+ // файлов content/blog в лямбде нет список был бы пуст. Читаем ВСЕ статьи на сборке; контент меняется
109
+ // git-коммитом редеплой пересобирает страницу. limit предохранитель (дефолт 1000).
110
+ // show/perPage приняты для обратной совместимости со старым скаффолдом (?show=), но ИГНОРИРУЮТСЯ.
111
+ export async function SeoBlogIndex(props: { locale?: string; basePath?: string; title?: string; limit?: number; show?: number; perPage?: number }) {
112
+ const m = await blogfs();
113
+ const locale = props.locale ?? m.fsDefaultLocale();
261
114
  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
115
  const basePath = (props.basePath ?? "/blog").replace(/\/+$/, "");
265
- // Тянем show+1, чтобы понять, есть ли ещё (для кнопки «Показать больше»).
266
- const posts = fs ? fs.fsBlogList(locale, show + 1) : (cfg ? await fetchPosts(cfg, locale, show + 1) : []);
267
- const hasMore = posts.length > show;
268
- const visible = posts.slice(0, show);
116
+ const limit = Math.max(1, Math.floor(props.limit ?? 1000));
117
+ const posts = m.fsBlogList(locale, limit);
118
+ const theme = m.fsTheme(); // тема из .blogfs.json
269
119
  return (
270
- <div className="sa-blog-wrap" style={themeStyle(cfg?.theme) as CSSProperties}>
120
+ <div className="sa-blog-wrap" style={themeStyle(theme) as CSSProperties}>
271
121
  <style dangerouslySetInnerHTML={{ __html: GRID_CSS }} />
272
122
  <h1 className="sa-blog-h1">{title}</h1>
273
- {visible.length ? (
274
- <>
275
- <div className="sa-blog-grid">
276
- {visible.map((p) => (
277
- <a key={p.slug} className="sa-blog-card" href={`${basePath}/${p.slug}`}>
278
- {p.cover ? <img src={p.cover} alt={p.title} loading="lazy" /> : null}
279
- <div className="sa-cb">
280
- <h2>{p.title}</h2>
281
- {p.excerpt ? <p>{p.excerpt}</p> : null}
282
- </div>
283
- </a>
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
- </>
123
+ {posts.length ? (
124
+ <div className="sa-blog-grid">
125
+ {posts.map((p) => (
126
+ <a key={p.slug} className="sa-blog-card" href={`${basePath}/${p.slug}`}>
127
+ {p.cover ? <img src={p.cover} alt={p.title} loading="lazy" /> : null}
128
+ <div className="sa-cb">
129
+ <h2>{p.title}</h2>
130
+ {p.excerpt ? <p>{p.excerpt}</p> : null}
131
+ </div>
132
+ </a>
133
+ ))}
134
+ </div>
292
135
  ) : (
293
136
  <p className="sa-blog-empty">{L(EMPTY_LABEL, locale, "No posts yet.")}</p>
294
137
  )}
@@ -304,7 +147,7 @@ export function SeoBlogArticle({ post, locale }: { post: SeoBlogPostFull; locale
304
147
  {post.cover ? <img src={post.cover} alt={post.title} style={{ width: "100%", borderRadius: "var(--sa-radius, 12px)", marginBottom: "1.5rem" }} /> : null}
305
148
  <h1 style={{ fontSize: "2.2rem", lineHeight: 1.2, margin: "0 0 1rem" }}>{post.title}</h1>
306
149
  {post.date ? <p style={{ color: "var(--sa-muted, #94a3b8)", margin: "0 0 1.5rem" }}>{fmtDate(post.date, locale)}</p> : null}
307
- {/* Тело уже санитайзено при доставке (Фаза 3.1b) — рендерим как HTML. */}
150
+ {/* Тело уже санитайзено при доставке — рендерим как HTML. */}
308
151
  <div className="seoagent-blog-body" dangerouslySetInnerHTML={{ __html: post.bodyHtml }} />
309
152
  </article>
310
153
  );
package/dist/blog-fs.d.ts CHANGED
@@ -1,6 +1,12 @@
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;
5
+ export declare function fsLocales(): string[];
4
6
  export declare function fsBlogList(locale: string, limit: number): SeoBlogSummary[];
5
7
  export declare function fsBlogPost(slug: string, locale: string): SeoBlogPostFull | null;
6
8
  export declare function fsDirExists(): boolean;
9
+ export declare function fsBlogSitemapEntries(origin: string): {
10
+ loc: string;
11
+ lastmod?: string;
12
+ }[];
package/dist/blog-fs.js CHANGED
@@ -1,10 +1,13 @@
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;
6
+ exports.fsLocales = fsLocales;
5
7
  exports.fsBlogList = fsBlogList;
6
8
  exports.fsBlogPost = fsBlogPost;
7
9
  exports.fsDirExists = fsDirExists;
10
+ exports.fsBlogSitemapEntries = fsBlogSitemapEntries;
8
11
  // Файловый источник блога (Фаза 4 blogfs): читает статьи из репозитория сайта (папка content/blog,
9
12
  // формат blogfs v1 — см. docs/blog-git-delivery/FORMAT.md) на СБОРКЕ, без сети и без Supabase.
10
13
  // Отдаёт те же формы, что и supabase-источник (SeoBlogSummary / SeoBlogPostFull) — blog.tsx роутит.
@@ -41,6 +44,26 @@ function readDescriptor() {
41
44
  return null;
42
45
  }
43
46
  }
47
+ // Тема оформления блога из дескриптора (git-режим, Фаза 3): ридер применяет её в --sa-* БЕЗ config-API.
48
+ // Валиден только объект с строковым accent (иначе игнор → дефолтные --sa-*).
49
+ function fsTheme() {
50
+ const t = readDescriptor()?.theme;
51
+ return t && typeof t === "object" && typeof t.accent === "string" && t.accent ? t : undefined;
52
+ }
53
+ // Канонический префикс публичных URL блога. Приоритет — явный descriptor.urlPrefix (пишет платформа);
54
+ // фолбэк для старых дескрипторов без него: <2 локалей → "" (одноязычный /blog), иначе "/<defaultLocale>".
55
+ function urlPrefix() {
56
+ const d = readDescriptor();
57
+ if (!d)
58
+ return "";
59
+ if (typeof d.urlPrefix === "string")
60
+ return d.urlPrefix.replace(/\/+$/, "");
61
+ const locales = Array.isArray(d.locales) ? d.locales.map((l) => String(l).trim()).filter(Boolean) : [];
62
+ if (locales.length < 2)
63
+ return "";
64
+ const dflt = (typeof d.defaultLocale === "string" && d.defaultLocale.trim()) || locales[0];
65
+ return dflt ? `/${dflt}` : "";
66
+ }
44
67
  // Есть ли файловый блог в репо (наличие .blogfs.json = git-сайт с ≥1 доставкой). Сигнал авто-детекта.
45
68
  function fsAvailable() {
46
69
  return readDescriptor() !== null;
@@ -49,6 +72,12 @@ function fsDefaultLocale() {
49
72
  const d = readDescriptor()?.defaultLocale;
50
73
  return typeof d === "string" ? d : ""; // битый дескриптор мог дать не-строку → не роняем split ниже
51
74
  }
75
+ // Локали доставки блога из дескриптора (для generateStaticParams мультиязычного роута /[locale]/blog/[slug]:
76
+ // каждую статью надо пре-рендерить под каждую локаль, иначе force-static отдаст 404). Пусто — одноязычный.
77
+ function fsLocales() {
78
+ const l = readDescriptor()?.locales;
79
+ return Array.isArray(l) ? l.map((x) => String(x).trim()).filter(Boolean) : [];
80
+ }
52
81
  // Собрать meta.json всех статей (glob по папкам). Пропускает битые/незнакомые версии/черновики.
53
82
  function listMetas() {
54
83
  const r = root();
@@ -139,7 +168,7 @@ function fsBlogPost(slug, locale) {
139
168
  ...s,
140
169
  // Тело уже санитайзено при доставке; чистим повторно (защита-в-глубину). bodyFormat="mdx" появится позже.
141
170
  bodyHtml: (0, sanitize_1.sanitizeDeliveryHtml)(bodyRaw),
142
- theme: undefined, // файловый источник не несёт тему платформы (используются --sa-* дефолты)
171
+ theme: fsTheme(), // тема из .blogfs.json (Фаза 3) фирменные цвета блога без запроса к платформе
143
172
  };
144
173
  }
145
174
  // Есть ли вообще папка блога (для мягких проверок).
@@ -151,3 +180,27 @@ function fsDirExists() {
151
180
  return false;
152
181
  }
153
182
  }
183
+ // Записи блога для sitemap (Фаза 2): страница списка + канонический URL каждой статьи, на СБОРКЕ из файлов.
184
+ // loc = origin + urlPrefix + "/blog[/<slug>]"; lastmod = meta.publishedAt (ISO), если есть. Одна каноническая
185
+ // ссылка на статью (префикс дефолтной локали) — как платформа пингует IndexNow; локали-альтернативы позже (hreflang).
186
+ // Fail-safe: любая ошибка → []. origin — https://домен (без хвостового слэша), берётся из карты сайта.
187
+ function fsBlogSitemapEntries(origin) {
188
+ try {
189
+ const base = (origin || "").replace(/\/+$/, "");
190
+ const pref = urlPrefix();
191
+ const metas = listMetas();
192
+ if (!metas.length)
193
+ return [];
194
+ const out = [{ loc: `${base}${pref}/blog` }];
195
+ for (const { meta } of metas) {
196
+ if (!meta.slug)
197
+ continue;
198
+ const lm = typeof meta.publishedAt === "string" && meta.publishedAt ? meta.publishedAt : undefined;
199
+ out.push({ loc: `${base}${pref}/blog/${meta.slug}`, lastmod: lm });
200
+ }
201
+ return out;
202
+ }
203
+ catch {
204
+ return [];
205
+ }
206
+ }
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;
@@ -43,14 +25,13 @@ export declare function seoBlogPost(slug: string, opts?: {
43
25
  locale?: string;
44
26
  }): Promise<SeoBlogPostFull | null>;
45
27
  export declare function seoBlogStaticParams(opts?: {
46
- locale?: string;
47
- }): Promise<{
48
- slug: string;
49
- }[]>;
28
+ localeParam?: string;
29
+ }): Promise<Array<Record<string, string>>>;
50
30
  export declare function SeoBlogIndex(props: {
51
31
  locale?: string;
52
32
  basePath?: string;
53
33
  title?: string;
34
+ limit?: number;
54
35
  show?: number;
55
36
  perPage?: number;
56
37
  }): Promise<import("react").JSX.Element>;
package/dist/blog.js CHANGED
@@ -39,189 +39,37 @@ exports.seoBlogStaticParams = seoBlogStaticParams;
39
39
  exports.SeoBlogIndex = SeoBlogIndex;
40
40
  exports.SeoBlogArticle = SeoBlogArticle;
41
41
  const jsx_runtime_1 = require("react/jsx-runtime");
42
- const sanitize_1 = require("./sanitize");
43
- const API = process.env.SEOAGENT_API_BASE ?? "";
44
- const SITE = process.env.SEOAGENT_SITE_ID ?? "";
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 fs = await fsActive();
172
- if (fs)
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 fs = await fsActive();
181
- if (fs)
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]: пре-рендер всех статей на сборке (SSG для
189
- // файлового источника; для supabase пре-рендер известных статей, новые добираются ISR по revalidate).
53
+ // Параметры для generateStaticParams роута блога: пре-рендер ВСЕХ статей на сборке (SSG из файлов).
54
+ // Без localeParam [{slug}] (одноязычный /blog/[slug]). С localeParam [{<localeParam>, slug}] для КАЖДОЙ
55
+ // локали (мультиязычный /[locale]/blog/[slug]): иначе force-static не покроет комбинацию locale×slug → 404.
190
56
  async function seoBlogStaticParams(opts) {
191
- const list = await seoBlogList({ locale: opts?.locale, limit: 100000 });
192
- return list.map((p) => ({ slug: p.slug }));
193
- }
194
- // ── Мини-разметка (## / - / **) → HTML (для legacy-формата; текст экранируем). ──
195
- function esc(s) {
196
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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("");
57
+ const list = await seoBlogList({ limit: 100000 });
58
+ const slugs = list.map((p) => ({ slug: p.slug }));
59
+ const lp = opts?.localeParam;
60
+ if (!lp)
61
+ return slugs;
62
+ const m = await blogfs();
63
+ const locales = m.fsLocales();
64
+ if (!locales.length)
65
+ return slugs;
66
+ return locales.flatMap((loc) => slugs.map((s) => ({ [lp]: loc, slug: s.slug })));
219
67
  }
220
68
  // ── Компоненты (серверные) ──────────────────────────────────────────────────────────────────
221
69
  // Цвета/рамки/скругления — через СОБСТВЕННЫЕ переменные пакета (--sa-*) с нейтральным дефолтом.
222
70
  // НЕ ссылаемся на токены сайта напрямую: shadcn/Tailwind хранят их как сырые HSL-компоненты
223
71
  // (--primary: 222 47% 11%), в color/border это невалидно и ломает вид. Реальные цвета сайта
224
- // проставит платформа: config-API отдаст профиль, а хост-обёртка выставит --sa-* валидными цветами.
72
+ // проставит платформа: .blogfs.json несёт тему, а хост-обёртка выставит --sa-* валидными цветами.
225
73
  // Шрифт и базовый цвет текста наследуются от layout сайта (color:inherit, без font-family).
226
74
  const BODY_CSS = `.seoagent-blog-body{color:inherit}
227
75
  .seoagent-blog-body img{max-width:100%;height:auto;border-radius:var(--sa-radius,8px);margin:1rem 0}
@@ -273,25 +121,22 @@ const GRID_CSS = `.sa-blog-wrap{max-width:1120px;margin:0 auto;padding:var(--sa-
273
121
  .sa-blog-more a:hover{background:var(--sa-th-bg,#f8fafc)}
274
122
  .sa-blog-empty{color:var(--sa-muted,#64748b)}`;
275
123
  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
124
  const EMPTY_LABEL = { ru: "Пока нет статей.", uz: "Hozircha maqolalar yo‘q.", en: "No posts yet.", ar: "لا توجد مقالات بعد." };
278
125
  const L = (map, locale, fb) => map[locale] || map[locale?.split("-")[0]] || fb;
279
- // Список статей блога: H1 + карточки (по 4 в ряд на десктопе) + «Показать больше» (пагинация по 20
280
- // через ?show=, серверно без клиентского JS, дружелюбно к SEO). Всегда рендерит H1 (даже пустой блог),
281
- // чтобы страница не была пустой. perPage размер «страницы» (дефолт 20), show сколько показать сейчас.
126
+ // Список статей блога: H1 + карточки (по 4 в ряд на десктопе). Всегда рендерит H1 (даже пустой блог).
127
+ // СТАТИЧЕСКИЙ (git-native): НЕ читает searchParams иначе роут стал бы динамическим, а в рантайме Vercel
128
+ // файлов content/blog в лямбде нет список был бы пуст. Читаем ВСЕ статьи на сборке; контент меняется
129
+ // git-коммитом → редеплой пересобирает страницу. limit — предохранитель (дефолт 1000).
130
+ // show/perPage приняты для обратной совместимости со старым скаффолдом (?show=), но ИГНОРИРУЮТСЯ.
282
131
  async function SeoBlogIndex(props) {
283
- const fs = await fsActive();
284
- const cfg = fs ? null : await fetchBlogConfig();
285
- const locale = props.locale ?? (fs ? fs.fsDefaultLocale() : (cfg ? firstLocale(cfg) : ""));
132
+ const m = await blogfs();
133
+ const locale = props.locale ?? m.fsDefaultLocale();
286
134
  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
135
  const basePath = (props.basePath ?? "/blog").replace(/\/+$/, "");
290
- // Тянем show+1, чтобы понять, есть ли ещё (для кнопки «Показать больше»).
291
- const posts = fs ? fs.fsBlogList(locale, show + 1) : (cfg ? await fetchPosts(cfg, locale, show + 1) : []);
292
- const hasMore = posts.length > show;
293
- const visible = posts.slice(0, show);
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.") }))] }));
136
+ const limit = Math.max(1, Math.floor(props.limit ?? 1000));
137
+ const posts = m.fsBlogList(locale, limit);
138
+ const theme = m.fsTheme(); // тема из .blogfs.json
139
+ 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
140
  }
296
141
  // Одна статья. Принимает пост (из seoBlogPost) — так страница может сделать notFound()/метаданные.
297
142
  function SeoBlogArticle({ post, locale }) {
@@ -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(groupOfUrl(e.loc))));
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) => groupOfUrl(e.loc) === g));
243
+ return urlsetXml(origin, entries.filter((e) => groupOf(e) === g));
199
244
  }
200
245
  // index
201
- const groups = [...new Set(entries.map((e) => groupOfUrl(e.loc)))].sort().filter((g) => !excluded.has(g));
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) => groupOfUrl(e.loc) === only));
252
+ return urlsetXml(origin, entries.filter((e) => groupOf(e) === only));
208
253
  }
209
254
  return indexXml(origin, groups);
210
255
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webnumseoagent/next",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Runtime SEO adapter + installer for Next.js App Router sites (SEO Agent platform)",
5
5
  "main": "dist/client.js",
6
6
  "types": "dist/client.d.ts",
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(groupOfUrl(e.loc))));
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) => groupOfUrl(e.loc) === g));
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) => groupOfUrl(e.loc)))].sort().filter((g) => !excluded.has(g));
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) => groupOfUrl(e.loc) === only));
250
+ return urlsetXml(origin, entries.filter((e) => groupOf(e) === only));
239
251
  }
240
252
  return indexXml(origin, groups);
241
253
  }