@webnumseoagent/next 0.5.0 → 0.7.0

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