@webnumseoagent/next 0.1.24 → 0.2.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/bin/cli.mjs CHANGED
@@ -97,13 +97,17 @@ async function init() {
97
97
  ok("Пакет @webnumseoagent/next установлен — импорт из пакета.");
98
98
  } else {
99
99
  const vendor = path.join(root, "seoagent");
100
- for (const f of ["client.ts", "jsonld.tsx", "sitemap-xsl.ts", "sitemap-build.ts", "redirects.ts"]) {
100
+ await fs.mkdir(vendor, { recursive: true });
101
+ // Вендор-файлы адаптера — генерируемые (их не правят руками): при повторном init ОБНОВЛЯЕМ,
102
+ // если содержимое изменилось. Иначе апгрейд оставит старый client.ts без новых экспортов
103
+ // (напр. seoMerchantFeedXml), а свежесозданный роут импортнёт несуществующий символ → build падёт.
104
+ for (const f of ["client.ts", "jsonld.tsx", "sitemap-xsl.ts", "sitemap-build.ts", "redirects.ts", "blog.tsx"]) {
101
105
  const dest = path.join(vendor, f);
102
- if (!(await exists(dest))) {
103
- await fs.mkdir(vendor, { recursive: true });
104
- await fs.copyFile(path.join(pkgRoot, f), dest);
105
- ok(`seoagent/${f}`);
106
- } else skip(`seoagent/${f}`);
106
+ const src = await fs.readFile(path.join(pkgRoot, f));
107
+ const cur = await fs.readFile(dest).catch(() => null);
108
+ if (cur && cur.equals(src)) { skip(`seoagent/${f}`); continue; }
109
+ await fs.writeFile(dest, src);
110
+ ok(`seoagent/${f}${cur ? " (обновлён)" : ""}`);
107
111
  }
108
112
  const imp = importPath(appDir, "sitemap.ts");
109
113
  importClient = `${imp}/client`;
@@ -113,6 +117,7 @@ async function init() {
113
117
  // Импорт клиента/jsonld для файла на нужной глубине (в vendor-режиме путь относительный).
114
118
  const clientFor = (file) => (pkgMode ? "@webnumseoagent/next" : `${importPath(appDir, file)}/client`);
115
119
  const jsonldFor = (file) => (pkgMode ? "@webnumseoagent/next/jsonld" : `${importPath(appDir, file)}/jsonld`);
120
+ const blogFor = (file) => (pkgMode ? "@webnumseoagent/next/blog" : `${importPath(appDir, file)}/blog`);
116
121
 
117
122
  // Не создаём sitemap/robots, если у сайта они УЖЕ есть (public/, отдельный роут и т.п.) —
118
123
  // чтобы не перекрыть существующую карту сайта пустой.
@@ -231,6 +236,14 @@ async function init() {
231
236
  `import { seoIndexNowKey } from "${clientFor("seoagent-indexnow.txt/route.ts")}";\nexport const dynamic = "force-dynamic";\nexport async function GET() {\n return new Response(await seoIndexNowKey(), { headers: { "content-type": "text/plain; charset=utf-8" } });\n}\n`,
232
237
  );
233
238
 
239
+ // 4.3 Google Merchant Center — товарный фид /seoagent-merchant-feed.xml (RSS 2.0 + g:).
240
+ // Адаптер лишь проксирует XML с платформы (та собирает его из индекса товаров сайта).
241
+ // Пустой валидный RSS, пока фид не включён в настройках — роут никогда не отдаёт 404.
242
+ await ensureFile(
243
+ path.join(root, appDir, `seoagent-merchant-feed.xml/route.${ext}`),
244
+ `import { seoMerchantFeedXml } from "${clientFor("seoagent-merchant-feed.xml/route.ts")}";\nexport const revalidate = 300;\nexport async function GET() {\n return new Response(await seoMerchantFeedXml("/merchant-feed.xml"), { headers: { "content-type": "application/xml; charset=utf-8", "x-seoagent-merchant": "1" } });\n}\n`,
245
+ );
246
+
234
247
  // 4.5 middleware (переадресации). Next разрешает ОДИН middleware, а авто-слияние чужого
235
248
  // небезопасно → создаём свой, только если своего нет; иначе печатаем ручную инструкцию.
236
249
  {
@@ -356,6 +369,37 @@ async function init() {
356
369
  if (!jsonLdMounted) {
357
370
  log(`\n И смонтируй JSON-LD в layout (внутри <body>):\n \x1b[36mimport { SeoJsonLd } from "${importJsonld}";\n <SeoJsonLd route="/" />\x1b[0m`);
358
371
  }
372
+
373
+ // 7. Блог (пакет-рендер, Фаза 3.1c) — ОПТ-ИН по флагу --blog: ставит роуты /blog и /blog/[slug].
374
+ // ПОСЛЕ авто-обёртки generateMetadata: у блог-роута свой generateMetadata (seoBlogPost), его НЕ
375
+ // оборачиваем seoMeta. Компоненты сами тянут конфиг+статьи; тело статьи уже санитайзено (доставка).
376
+ if (args.includes("--blog")) {
377
+ const jsxExt = ext === "ts" ? "tsx" : "jsx";
378
+ const mdParam = ext === "ts" ? "{ params }: { params: { slug: string } }" : "{ params }";
379
+ await ensureFile(
380
+ path.join(root, appDir, `blog/page.${jsxExt}`),
381
+ `import { SeoBlogIndex } from "${blogFor("blog/page.tsx")}";\nexport const revalidate = 300;\nexport default function BlogPage() {\n return <SeoBlogIndex title="Блог" />;\n}\n`,
382
+ );
383
+ // У сайта уже может быть app/blog/[иной-сегмент] — Next не даёт два разных динамических имени
384
+ // на один путь. Тогда наш /blog/[slug] не создаём (иначе билд упадёт), а печатаем ручную инструкцию.
385
+ let blogDynConflict = false;
386
+ const blogDir = path.join(root, appDir, "blog");
387
+ if (await exists(blogDir)) {
388
+ const names = await fs.readdir(blogDir).catch(() => []);
389
+ blogDynConflict = names.some((n) => /^\[.*\]$/.test(n) && n !== "[slug]");
390
+ }
391
+ if (blogDynConflict) {
392
+ log(`\n \x1b[1mУ сайта уже есть app/${appDir === "src/app" ? "" : ""}blog/[иной-сегмент]\x1b[0m — роут статьи /blog/[slug] не создаю (Next запрещает два динамических сегмента).`);
393
+ log(` Подключи статьи к своему сегменту вручную: \x1b[36mimport { seoBlogPost, SeoBlogArticle } from "${blogFor("blog/[slug]/page.tsx")}";\x1b[0m`);
394
+ } else {
395
+ await ensureFile(
396
+ path.join(root, appDir, `blog/[slug]/page.${jsxExt}`),
397
+ `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`,
398
+ );
399
+ }
400
+ log(`\n \x1b[32m✓\x1b[0m Блог: роут /blog (пакет-рендер). Настрой доставку+anon-ключ на платформе.`);
401
+ }
402
+
359
403
  log("\n\x1b[1mЗатем:\x1b[0m задай эти же env-переменные в Vercel (Settings → Environment Variables) и сделай git push.\n");
360
404
  }
361
405
 
@@ -408,6 +452,10 @@ async function findMetadataPages(dir, acc = []) {
408
452
  await findMetadataPages(p, acc);
409
453
  } else if (/\.(tsx|ts|jsx|js)$/.test(e.name) && /page\.|layout\./.test(e.name)) {
410
454
  const txt = await fs.readFile(p, "utf8");
455
+ // Наши блог-роуты (пакет-рендер) имеют СВОЙ generateMetadata (seoBlogPost) — НЕ оборачиваем
456
+ // их seoMeta даже при повторном init --wrap (иначе перетрём пер-статейное SEO). Отсекаем по
457
+ // уникальным символам скаффолда, а не по папке blog/ (у сайта может быть свой блог для обёртки).
458
+ if (/\b(SeoBlogIndex|SeoBlogArticle|seoBlogPost|seoBlogList)\b/.test(txt)) continue;
411
459
  // Собираем и функции generateMetadata, и статический `export const metadata`.
412
460
  if (/generateMetadata/.test(txt) || /export\s+const\s+metadata\b/.test(txt)) acc.push(p);
413
461
  }
package/blog.tsx ADDED
@@ -0,0 +1,271 @@
1
+ // @webnumseoagent/next/blog — рендер блога код-сайта (Фаза 3.1c).
2
+ // Пакет тянет ПУБЛИЧНЫЙ конфиг блога с платформы (config-API /blog: supabaseUrl + anonKey +
3
+ // таблица/формат/локали/маппинг), затем читает ОПУБЛИКОВАННЫЕ статьи прямо из Supabase клиента
4
+ // (anon-ключ + public-read RLS) и рендерит обложку + заголовок + тело (картинки/таблицы/ссылки).
5
+ //
6
+ // Тело статьи уже САНИТАЙЗЕНО на стороне платформы при доставке (Фаза 3.1b), поэтому здесь оно
7
+ // рендерится как HTML (dangerouslySetInnerHTML) — граница доверия проходит по доставке, не по рендеру.
8
+ //
9
+ // Все функции FAIL-SAFE: при ошибке/таймауте возвращают пусто — блог просто не покажет контент,
10
+ // сборка/рендер сайта НИКОГДА не падает.
11
+ //
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} />
16
+
17
+ const API = process.env.SEOAGENT_API_BASE ?? "";
18
+ const SITE = process.env.SEOAGENT_SITE_ID ?? "";
19
+ const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
20
+ const REVALIDATE = 300;
21
+
22
+ export interface SeoBlogMapping {
23
+ title: string; excerpt?: string; body: string;
24
+ id?: string; image?: string; published?: string; publishedAt?: string;
25
+ }
26
+ export interface SeoBlogConfig {
27
+ supabaseUrl: string; anonKey: string; table: string;
28
+ format: "html" | "minimarkup"; locales: string[]; mapping: SeoBlogMapping;
29
+ }
30
+ export interface SeoBlogSummary { slug: string; title: string; excerpt: string; cover: string | null; date: string | null }
31
+ export interface SeoBlogPostFull extends SeoBlogSummary { bodyHtml: string }
32
+
33
+ function firstLocale(cfg: SeoBlogConfig): string {
34
+ return (cfg.locales ?? []).filter(Boolean)[0] ?? "";
35
+ }
36
+
37
+ // Публичный конфиг блога с платформы (кэш ISR + тег seo-<site> — сбрасывается ревалидацией).
38
+ async function fetchBlogConfig(): Promise<SeoBlogConfig | null> {
39
+ if (!API || !SITE || !TOKEN) return null;
40
+ try {
41
+ const r = await fetch(`${API}/api/seo/${SITE}/blog`, {
42
+ headers: { "x-seo-agent-key": TOKEN },
43
+ next: { revalidate: REVALIDATE, tags: [`seo-${SITE}`, `seo-blog-${SITE}`] },
44
+ signal: AbortSignal.timeout(5000),
45
+ });
46
+ if (!r.ok) return null;
47
+ const d = (await r.json()) as { blog?: SeoBlogConfig | null };
48
+ const b = d?.blog ?? null;
49
+ if (!b?.supabaseUrl || !b?.anonKey || !b?.table || !b?.mapping?.title || !b?.mapping?.body) return null;
50
+ return b;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ // Запрос к REST API Supabase клиента (anon). Возвращает массив строк либо null.
57
+ async function supaGet(cfg: SeoBlogConfig, url: string): Promise<Record<string, unknown>[] | null> {
58
+ try {
59
+ const r = await fetch(url, {
60
+ headers: { apikey: cfg.anonKey, Authorization: `Bearer ${cfg.anonKey}` },
61
+ next: { revalidate: REVALIDATE, tags: [`seo-blog-${SITE}`] },
62
+ signal: AbortSignal.timeout(6000),
63
+ });
64
+ if (!r.ok) return null;
65
+ const d = await r.json();
66
+ return Array.isArray(d) ? d : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ const S = (v: unknown): string => (v == null ? "" : String(v));
73
+ const base = (cfg: SeoBlogConfig): string => cfg.supabaseUrl.replace(/\/+$/, "");
74
+
75
+ async function fetchPosts(cfg: SeoBlogConfig, locale: string, limit: number): Promise<SeoBlogSummary[]> {
76
+ const m = cfg.mapping;
77
+ if (!m.id) return []; // без колонки id/slug ссылки на статьи не построить
78
+ const suf = locale ? `_${locale}` : "";
79
+ const titleCol = m.title + suf;
80
+ const excerptCol = m.excerpt ? m.excerpt + suf : "";
81
+ const cols = [m.id, titleCol, excerptCol, m.image || "", m.publishedAt || ""].filter(Boolean).join(",");
82
+ let url = `${base(cfg)}/rest/v1/${encodeURIComponent(cfg.table)}?select=${cols}`;
83
+ if (m.published) url += `&${m.published}=eq.true`;
84
+ if (m.publishedAt) url += `&order=${m.publishedAt}.desc`;
85
+ url += `&limit=${limit}`;
86
+ const rows = await supaGet(cfg, url);
87
+ return (rows ?? [])
88
+ .map((r) => ({
89
+ slug: S(r[m.id!]),
90
+ title: S(r[titleCol]),
91
+ excerpt: excerptCol ? S(r[excerptCol]) : "",
92
+ cover: m.image ? (r[m.image] as string | null) ?? null : null,
93
+ date: m.publishedAt ? (r[m.publishedAt] as string | null) ?? null : null,
94
+ }))
95
+ .filter((p) => p.slug && p.title);
96
+ }
97
+
98
+ async function fetchPost(cfg: SeoBlogConfig, slug: string, locale: string): Promise<SeoBlogPostFull | null> {
99
+ const m = cfg.mapping;
100
+ if (!m.id) return null;
101
+ const suf = locale ? `_${locale}` : "";
102
+ let url = `${base(cfg)}/rest/v1/${encodeURIComponent(cfg.table)}?select=*&${m.id}=eq.${encodeURIComponent(slug)}`;
103
+ if (m.published) url += `&${m.published}=eq.true`;
104
+ url += `&limit=1`;
105
+ const rows = await supaGet(cfg, url);
106
+ const r = rows && rows[0];
107
+ if (!r) return null;
108
+ const rawBody = S(r[m.body + suf]);
109
+ return {
110
+ slug: S(r[m.id]) || slug,
111
+ title: S(r[m.title + suf]),
112
+ excerpt: m.excerpt ? S(r[m.excerpt + suf]) : "",
113
+ cover: m.image ? (r[m.image] as string | null) ?? null : null,
114
+ date: m.publishedAt ? (r[m.publishedAt] as string | null) ?? null : null,
115
+ bodyHtml: cfg.format === "minimarkup" ? miniMarkupToHtml(rawBody) : sanitizeDeliveryHtml(rawBody),
116
+ };
117
+ }
118
+
119
+ // ── Публичные data-функции (для generateMetadata / generateStaticParams) ──
120
+ export async function seoBlogList(opts?: { locale?: string; limit?: number }): Promise<SeoBlogSummary[]> {
121
+ const cfg = await fetchBlogConfig();
122
+ if (!cfg) return [];
123
+ return fetchPosts(cfg, opts?.locale ?? firstLocale(cfg), opts?.limit ?? 100);
124
+ }
125
+ export async function seoBlogPost(slug: string, opts?: { locale?: string }): Promise<SeoBlogPostFull | null> {
126
+ const cfg = await fetchBlogConfig();
127
+ if (!cfg) return null;
128
+ return fetchPost(cfg, slug, opts?.locale ?? firstLocale(cfg));
129
+ }
130
+
131
+ // ── Мини-разметка (## / - / **) → HTML (для legacy-формата; текст экранируем). ──
132
+ function esc(s: string): string {
133
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
134
+ }
135
+ function inlineMini(s: string): string {
136
+ return esc(s).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
137
+ }
138
+ function miniMarkupToHtml(src: string): string {
139
+ const out: string[] = [];
140
+ for (const block of (src || "").split(/\n{2,}/)) {
141
+ const b = block.trim();
142
+ if (!b) continue;
143
+ const lines = b.split("\n");
144
+ if (lines.every((l) => l.startsWith("- "))) {
145
+ out.push("<ul>" + lines.map((l) => `<li>${inlineMini(l.slice(2))}</li>`).join("") + "</ul>");
146
+ } else if (b.startsWith("## ")) {
147
+ out.push(`<h2>${inlineMini(b.slice(3))}</h2>`);
148
+ } else {
149
+ out.push(`<p>${inlineMini(b)}</p>`);
150
+ }
151
+ }
152
+ return out.join("");
153
+ }
154
+
155
+ // ── Санитайзер HTML (защита-в-глубину перед dangerouslySetInnerHTML) — ЗЕРКАЛО доставки
156
+ // (src/lib/blog/blog-delivery.ts + Edge blog-autopilot). Тело уже санитайзено при доставке, но
157
+ // если у сайта не настроен RLS (провиженинг опционален) — это последний рубеж. ──
158
+ const SANITIZE_ALLOWED_TAGS = new Set([
159
+ "h1", "h2", "h3", "h4", "h5", "h6", "p", "br", "hr", "strong", "b", "em", "i", "u", "s",
160
+ "ul", "ol", "li", "blockquote", "a", "img", "figure", "figcaption",
161
+ "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "code", "pre", "span", "div",
162
+ ]);
163
+ const SANITIZE_ALLOWED_ATTRS = /^(href|src|alt|title|target|rel|width|height|loading|colspan|rowspan|scope|start|type)$/;
164
+ const SANITIZE_VOID = new Set(["br", "hr", "img"]);
165
+ function decodeSchemeEntities(v: string): string {
166
+ return v
167
+ .replace(/&#x([0-9a-fA-F]+);?/g, (_m, h) => { try { return String.fromCodePoint(parseInt(h, 16)); } catch { return ""; } })
168
+ .replace(/&#(\d+);?/g, (_m, d) => { try { return String.fromCodePoint(parseInt(d, 10)); } catch { return ""; } })
169
+ .replace(/&(colon|tab|newline|lf|cr|sol|amp);?/gi, (m) => {
170
+ const k = m.replace(/[&;]/g, "").toLowerCase();
171
+ return ({ colon: ":", sol: "/", amp: "&", tab: " ", newline: " ", lf: " ", cr: " " } as Record<string, string>)[k] ?? " ";
172
+ });
173
+ }
174
+ function safeUrl(val: string): boolean {
175
+ const v = decodeSchemeEntities(val).toLowerCase().replace(/\s/g, "");
176
+ if (!/^[a-z][a-z0-9+.-]*:/.test(v)) return true;
177
+ return /^(https?:|mailto:|tel:)/.test(v);
178
+ }
179
+ function sanitizeAttrs(tag: string, attrsRaw: string): string {
180
+ const out: string[] = [];
181
+ const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
182
+ let m: RegExpExecArray | null;
183
+ let href = "";
184
+ while ((m = re.exec(attrsRaw))) {
185
+ const name = m[1].toLowerCase();
186
+ const val = m[3] ?? m[4] ?? m[5] ?? "";
187
+ if (name.startsWith("on") || name === "style" || name === "srcset") continue;
188
+ if ((name === "href" || name === "src") && !safeUrl(val)) continue;
189
+ if (!SANITIZE_ALLOWED_ATTRS.test(name)) continue;
190
+ if (name === "href") href = val;
191
+ out.push(`${name}="${val.replace(/"/g, "&quot;")}"`);
192
+ }
193
+ if (tag === "a" && !out.some((a) => a.startsWith("rel="))) {
194
+ if (/^(https?:)?\/\//i.test(href.trim())) out.push('rel="noopener nofollow"');
195
+ }
196
+ return out.length ? " " + out.join(" ") : "";
197
+ }
198
+ function sanitizeDeliveryHtml(html: string): string {
199
+ let s = html || "";
200
+ s = s.replace(/<(script|style|iframe|object|embed|form|noscript|template|svg|math)\b[\s\S]*?<\/\1\s*>/gi, "");
201
+ s = s.replace(/<!--[\s\S]*?-->/g, "");
202
+ s = s.replace(/<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b((?:"[^"]*"|'[^']*'|[^>])*)>/g, (_full, slash, tag, attrs) => {
203
+ const t = tag.toLowerCase();
204
+ if (!SANITIZE_ALLOWED_TAGS.has(t)) return "";
205
+ if (slash) return `</${t}>`;
206
+ return `<${t}${sanitizeAttrs(t, attrs)}${SANITIZE_VOID.has(t) ? " /" : ""}>`;
207
+ });
208
+ return s.trim();
209
+ }
210
+
211
+ // ── Компоненты (серверные) ──────────────────────────────────────────────────────────────────
212
+ const BODY_CSS = `.seoagent-blog-body img{max-width:100%;height:auto;border-radius:8px;margin:1rem 0}
213
+ .seoagent-blog-body table{width:100%;border-collapse:collapse;margin:1rem 0;font-size:.95em}
214
+ .seoagent-blog-body th,.seoagent-blog-body td{border:1px solid #e5e7eb;padding:.5rem .75rem;text-align:left}
215
+ .seoagent-blog-body th{background:#f8fafc}
216
+ .seoagent-blog-body h2{margin:1.75rem 0 .75rem;font-size:1.5rem;line-height:1.3}
217
+ .seoagent-blog-body h3{margin:1.4rem 0 .6rem;font-size:1.2rem}
218
+ .seoagent-blog-body p{margin:0 0 1rem}
219
+ .seoagent-blog-body a{color:#2563eb}
220
+ .seoagent-blog-body figure{margin:1.25rem 0}
221
+ .seoagent-blog-body figcaption{color:#94a3b8;font-size:.85em;text-align:center;margin-top:.4rem}
222
+ .seoagent-blog-body blockquote{margin:1rem 0;padding:.5rem 1rem;border-left:3px solid #e5e7eb;color:#475569}`;
223
+
224
+ // Список статей блога. Сам тянет конфиг+статьи. Пусто/ошибка → null (ничего не ломаем).
225
+ export async function SeoBlogIndex(props: { locale?: string; basePath?: string; limit?: number; title?: string }) {
226
+ const cfg = await fetchBlogConfig();
227
+ if (!cfg) return null;
228
+ const locale = props.locale ?? firstLocale(cfg);
229
+ const posts = await fetchPosts(cfg, locale, props.limit ?? 30);
230
+ if (!posts.length) return null;
231
+ const basePath = (props.basePath ?? "/blog").replace(/\/+$/, "");
232
+ return (
233
+ <div style={{ maxWidth: 1080, margin: "0 auto", padding: "1.5rem" }}>
234
+ {props.title ? <h1 style={{ fontSize: "2rem", margin: "0 0 1.5rem" }}>{props.title}</h1> : null}
235
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: "1.5rem" }}>
236
+ {posts.map((p) => (
237
+ <a key={p.slug} href={`${basePath}/${p.slug}`}
238
+ style={{ display: "block", textDecoration: "none", color: "inherit", border: "1px solid #e5e7eb", borderRadius: 12, overflow: "hidden" }}>
239
+ {p.cover ? <img src={p.cover} alt={p.title} style={{ width: "100%", height: 180, objectFit: "cover", display: "block" }} /> : null}
240
+ <div style={{ padding: "1rem" }}>
241
+ <h2 style={{ fontSize: "1.1rem", margin: "0 0 .5rem", lineHeight: 1.3 }}>{p.title}</h2>
242
+ {p.excerpt ? <p style={{ margin: 0, color: "#64748b", fontSize: ".9rem", lineHeight: 1.5 }}>{p.excerpt}</p> : null}
243
+ </div>
244
+ </a>
245
+ ))}
246
+ </div>
247
+ </div>
248
+ );
249
+ }
250
+
251
+ // Одна статья. Принимает пост (из seoBlogPost) — так страница может сделать notFound()/метаданные.
252
+ export function SeoBlogArticle({ post, locale }: { post: SeoBlogPostFull; locale?: string }) {
253
+ return (
254
+ <article style={{ maxWidth: 760, margin: "0 auto", padding: "1.5rem", lineHeight: 1.7 }}>
255
+ <style dangerouslySetInnerHTML={{ __html: BODY_CSS }} />
256
+ {post.cover ? <img src={post.cover} alt={post.title} style={{ width: "100%", borderRadius: 12, marginBottom: "1.5rem" }} /> : null}
257
+ <h1 style={{ fontSize: "2.2rem", lineHeight: 1.2, margin: "0 0 1rem" }}>{post.title}</h1>
258
+ {post.date ? <p style={{ color: "#94a3b8", margin: "0 0 1.5rem" }}>{fmtDate(post.date, locale)}</p> : null}
259
+ {/* Тело уже санитайзено при доставке (Фаза 3.1b) — рендерим как HTML. */}
260
+ <div className="seoagent-blog-body" dangerouslySetInnerHTML={{ __html: post.bodyHtml }} />
261
+ </article>
262
+ );
263
+ }
264
+
265
+ function fmtDate(d: string, locale?: string): string {
266
+ try {
267
+ return new Date(d).toLocaleDateString(locale || undefined, { year: "numeric", month: "long", day: "numeric" });
268
+ } catch {
269
+ return "";
270
+ }
271
+ }
package/client.ts CHANGED
@@ -254,6 +254,27 @@ export async function seoSitemapXml(path: string): Promise<string> {
254
254
  }
255
255
  }
256
256
 
257
+ // Товарный фид Google Merchant Center (RSS 2.0 + g:-namespace): сырой XML с платформы.
258
+ // Платформа собирает его из закэшированного индекса товаров (быстро, без живого краула).
259
+ // app/seoagent-merchant-feed.xml/route.ts:
260
+ // export async function GET() { return new Response(await seoMerchantFeedXml("/merchant-feed.xml"), {headers:{"content-type":"application/xml"}}) }
261
+ export async function seoMerchantFeedXml(path: string): Promise<string> {
262
+ // Валидный RSS-канал (title/link/description обязательны в RSS 2.0) с пустым списком товаров.
263
+ const FALLBACK = `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">\n<channel>\n <title>Merchant feed</title>\n <link>https://seonum.uz</link>\n <description>Merchant feed temporarily unavailable</description>\n</channel>\n</rss>\n`;
264
+ if (!API || !SITE || !TOKEN) return FALLBACK;
265
+ try {
266
+ const r = await fetch(`${API}/api/seo/${SITE}${path}`, {
267
+ headers: { "x-seo-agent-key": TOKEN },
268
+ next: { revalidate: 300, tags: [`seo-${SITE}`] },
269
+ signal: AbortSignal.timeout(8000),
270
+ });
271
+ if (!r.ok) return FALLBACK;
272
+ return await r.text();
273
+ } catch {
274
+ return FALLBACK;
275
+ }
276
+ }
277
+
257
278
  export { SITEMAP_XSL } from "./sitemap-xsl";
258
279
  export { seoWrapSitemap } from "./sitemap-build";
259
280
 
package/dist/blog.d.ts ADDED
@@ -0,0 +1,44 @@
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
+ export interface SeoBlogConfig {
11
+ supabaseUrl: string;
12
+ anonKey: string;
13
+ table: string;
14
+ format: "html" | "minimarkup";
15
+ locales: string[];
16
+ mapping: SeoBlogMapping;
17
+ }
18
+ export interface SeoBlogSummary {
19
+ slug: string;
20
+ title: string;
21
+ excerpt: string;
22
+ cover: string | null;
23
+ date: string | null;
24
+ }
25
+ export interface SeoBlogPostFull extends SeoBlogSummary {
26
+ bodyHtml: string;
27
+ }
28
+ export declare function seoBlogList(opts?: {
29
+ locale?: string;
30
+ limit?: number;
31
+ }): Promise<SeoBlogSummary[]>;
32
+ export declare function seoBlogPost(slug: string, opts?: {
33
+ locale?: string;
34
+ }): Promise<SeoBlogPostFull | null>;
35
+ export declare function SeoBlogIndex(props: {
36
+ locale?: string;
37
+ basePath?: string;
38
+ limit?: number;
39
+ title?: string;
40
+ }): Promise<import("react").JSX.Element | null>;
41
+ export declare function SeoBlogArticle({ post, locale }: {
42
+ post: SeoBlogPostFull;
43
+ locale?: string;
44
+ }): import("react").JSX.Element;
package/dist/blog.js ADDED
@@ -0,0 +1,266 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.seoBlogList = seoBlogList;
4
+ exports.seoBlogPost = seoBlogPost;
5
+ exports.SeoBlogIndex = SeoBlogIndex;
6
+ exports.SeoBlogArticle = SeoBlogArticle;
7
+ const jsx_runtime_1 = require("react/jsx-runtime");
8
+ // @webnumseoagent/next/blog — рендер блога код-сайта (Фаза 3.1c).
9
+ // Пакет тянет ПУБЛИЧНЫЙ конфиг блога с платформы (config-API /blog: supabaseUrl + anonKey +
10
+ // таблица/формат/локали/маппинг), затем читает ОПУБЛИКОВАННЫЕ статьи прямо из Supabase клиента
11
+ // (anon-ключ + public-read RLS) и рендерит обложку + заголовок + тело (картинки/таблицы/ссылки).
12
+ //
13
+ // Тело статьи уже САНИТАЙЗЕНО на стороне платформы при доставке (Фаза 3.1b), поэтому здесь оно
14
+ // рендерится как HTML (dangerouslySetInnerHTML) — граница доверия проходит по доставке, не по рендеру.
15
+ //
16
+ // Все функции FAIL-SAFE: при ошибке/таймауте возвращают пусто — блог просто не покажет контент,
17
+ // сборка/рендер сайта НИКОГДА не падает.
18
+ //
19
+ // Использование (роуты ставит `npx @webnumseoagent/next init --blog`):
20
+ // app/blog/page.tsx: export default () => <SeoBlogIndex title="Блог" />
21
+ // app/blog/[slug]/page.tsx: const post = await seoBlogPost(params.slug); if(!post) notFound();
22
+ // return <SeoBlogArticle post={post} />
23
+ const API = process.env.SEOAGENT_API_BASE ?? "";
24
+ const SITE = process.env.SEOAGENT_SITE_ID ?? "";
25
+ const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
26
+ const REVALIDATE = 300;
27
+ function firstLocale(cfg) {
28
+ return (cfg.locales ?? []).filter(Boolean)[0] ?? "";
29
+ }
30
+ // Публичный конфиг блога с платформы (кэш ISR + тег seo-<site> — сбрасывается ревалидацией).
31
+ async function fetchBlogConfig() {
32
+ if (!API || !SITE || !TOKEN)
33
+ return null;
34
+ try {
35
+ const r = await fetch(`${API}/api/seo/${SITE}/blog`, {
36
+ headers: { "x-seo-agent-key": TOKEN },
37
+ next: { revalidate: REVALIDATE, tags: [`seo-${SITE}`, `seo-blog-${SITE}`] },
38
+ signal: AbortSignal.timeout(5000),
39
+ });
40
+ if (!r.ok)
41
+ return null;
42
+ const d = (await r.json());
43
+ const b = d?.blog ?? null;
44
+ if (!b?.supabaseUrl || !b?.anonKey || !b?.table || !b?.mapping?.title || !b?.mapping?.body)
45
+ return null;
46
+ return b;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ // Запрос к REST API Supabase клиента (anon). Возвращает массив строк либо null.
53
+ async function supaGet(cfg, url) {
54
+ try {
55
+ const r = await fetch(url, {
56
+ headers: { apikey: cfg.anonKey, Authorization: `Bearer ${cfg.anonKey}` },
57
+ next: { revalidate: REVALIDATE, tags: [`seo-blog-${SITE}`] },
58
+ signal: AbortSignal.timeout(6000),
59
+ });
60
+ if (!r.ok)
61
+ return null;
62
+ const d = await r.json();
63
+ return Array.isArray(d) ? d : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ const S = (v) => (v == null ? "" : String(v));
70
+ const base = (cfg) => cfg.supabaseUrl.replace(/\/+$/, "");
71
+ async function fetchPosts(cfg, locale, limit) {
72
+ const m = cfg.mapping;
73
+ if (!m.id)
74
+ return []; // без колонки id/slug ссылки на статьи не построить
75
+ const suf = locale ? `_${locale}` : "";
76
+ const titleCol = m.title + suf;
77
+ const excerptCol = m.excerpt ? m.excerpt + suf : "";
78
+ const cols = [m.id, titleCol, excerptCol, m.image || "", m.publishedAt || ""].filter(Boolean).join(",");
79
+ let url = `${base(cfg)}/rest/v1/${encodeURIComponent(cfg.table)}?select=${cols}`;
80
+ if (m.published)
81
+ url += `&${m.published}=eq.true`;
82
+ if (m.publishedAt)
83
+ url += `&order=${m.publishedAt}.desc`;
84
+ url += `&limit=${limit}`;
85
+ const rows = await supaGet(cfg, url);
86
+ return (rows ?? [])
87
+ .map((r) => ({
88
+ slug: S(r[m.id]),
89
+ title: S(r[titleCol]),
90
+ excerpt: excerptCol ? S(r[excerptCol]) : "",
91
+ cover: m.image ? r[m.image] ?? null : null,
92
+ date: m.publishedAt ? r[m.publishedAt] ?? null : null,
93
+ }))
94
+ .filter((p) => p.slug && p.title);
95
+ }
96
+ async function fetchPost(cfg, slug, locale) {
97
+ const m = cfg.mapping;
98
+ if (!m.id)
99
+ return null;
100
+ const suf = locale ? `_${locale}` : "";
101
+ let url = `${base(cfg)}/rest/v1/${encodeURIComponent(cfg.table)}?select=*&${m.id}=eq.${encodeURIComponent(slug)}`;
102
+ if (m.published)
103
+ url += `&${m.published}=eq.true`;
104
+ url += `&limit=1`;
105
+ const rows = await supaGet(cfg, url);
106
+ const r = rows && rows[0];
107
+ if (!r)
108
+ return null;
109
+ const rawBody = S(r[m.body + suf]);
110
+ return {
111
+ slug: S(r[m.id]) || slug,
112
+ title: S(r[m.title + suf]),
113
+ excerpt: m.excerpt ? S(r[m.excerpt + suf]) : "",
114
+ cover: m.image ? r[m.image] ?? null : null,
115
+ date: m.publishedAt ? r[m.publishedAt] ?? null : null,
116
+ bodyHtml: cfg.format === "minimarkup" ? miniMarkupToHtml(rawBody) : sanitizeDeliveryHtml(rawBody),
117
+ };
118
+ }
119
+ // ── Публичные data-функции (для generateMetadata / generateStaticParams) ──
120
+ async function seoBlogList(opts) {
121
+ const cfg = await fetchBlogConfig();
122
+ if (!cfg)
123
+ return [];
124
+ return fetchPosts(cfg, opts?.locale ?? firstLocale(cfg), opts?.limit ?? 100);
125
+ }
126
+ async function seoBlogPost(slug, opts) {
127
+ const cfg = await fetchBlogConfig();
128
+ if (!cfg)
129
+ return null;
130
+ return fetchPost(cfg, slug, opts?.locale ?? firstLocale(cfg));
131
+ }
132
+ // ── Мини-разметка (## / - / **) → HTML (для legacy-формата; текст экранируем). ──
133
+ function esc(s) {
134
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
135
+ }
136
+ function inlineMini(s) {
137
+ return esc(s).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
138
+ }
139
+ function miniMarkupToHtml(src) {
140
+ const out = [];
141
+ for (const block of (src || "").split(/\n{2,}/)) {
142
+ const b = block.trim();
143
+ if (!b)
144
+ continue;
145
+ const lines = b.split("\n");
146
+ if (lines.every((l) => l.startsWith("- "))) {
147
+ out.push("<ul>" + lines.map((l) => `<li>${inlineMini(l.slice(2))}</li>`).join("") + "</ul>");
148
+ }
149
+ else if (b.startsWith("## ")) {
150
+ out.push(`<h2>${inlineMini(b.slice(3))}</h2>`);
151
+ }
152
+ else {
153
+ out.push(`<p>${inlineMini(b)}</p>`);
154
+ }
155
+ }
156
+ return out.join("");
157
+ }
158
+ // ── Санитайзер HTML (защита-в-глубину перед dangerouslySetInnerHTML) — ЗЕРКАЛО доставки
159
+ // (src/lib/blog/blog-delivery.ts + Edge blog-autopilot). Тело уже санитайзено при доставке, но
160
+ // если у сайта не настроен RLS (провиженинг опционален) — это последний рубеж. ──
161
+ const SANITIZE_ALLOWED_TAGS = new Set([
162
+ "h1", "h2", "h3", "h4", "h5", "h6", "p", "br", "hr", "strong", "b", "em", "i", "u", "s",
163
+ "ul", "ol", "li", "blockquote", "a", "img", "figure", "figcaption",
164
+ "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "code", "pre", "span", "div",
165
+ ]);
166
+ const SANITIZE_ALLOWED_ATTRS = /^(href|src|alt|title|target|rel|width|height|loading|colspan|rowspan|scope|start|type)$/;
167
+ const SANITIZE_VOID = new Set(["br", "hr", "img"]);
168
+ function decodeSchemeEntities(v) {
169
+ return v
170
+ .replace(/&#x([0-9a-fA-F]+);?/g, (_m, h) => { try {
171
+ return String.fromCodePoint(parseInt(h, 16));
172
+ }
173
+ catch {
174
+ return "";
175
+ } })
176
+ .replace(/&#(\d+);?/g, (_m, d) => { try {
177
+ return String.fromCodePoint(parseInt(d, 10));
178
+ }
179
+ catch {
180
+ return "";
181
+ } })
182
+ .replace(/&(colon|tab|newline|lf|cr|sol|amp);?/gi, (m) => {
183
+ const k = m.replace(/[&;]/g, "").toLowerCase();
184
+ return { colon: ":", sol: "/", amp: "&", tab: " ", newline: " ", lf: " ", cr: " " }[k] ?? " ";
185
+ });
186
+ }
187
+ function safeUrl(val) {
188
+ const v = decodeSchemeEntities(val).toLowerCase().replace(/\s/g, "");
189
+ if (!/^[a-z][a-z0-9+.-]*:/.test(v))
190
+ return true;
191
+ return /^(https?:|mailto:|tel:)/.test(v);
192
+ }
193
+ function sanitizeAttrs(tag, attrsRaw) {
194
+ const out = [];
195
+ const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
196
+ let m;
197
+ let href = "";
198
+ while ((m = re.exec(attrsRaw))) {
199
+ const name = m[1].toLowerCase();
200
+ const val = m[3] ?? m[4] ?? m[5] ?? "";
201
+ if (name.startsWith("on") || name === "style" || name === "srcset")
202
+ continue;
203
+ if ((name === "href" || name === "src") && !safeUrl(val))
204
+ continue;
205
+ if (!SANITIZE_ALLOWED_ATTRS.test(name))
206
+ continue;
207
+ if (name === "href")
208
+ href = val;
209
+ out.push(`${name}="${val.replace(/"/g, "&quot;")}"`);
210
+ }
211
+ if (tag === "a" && !out.some((a) => a.startsWith("rel="))) {
212
+ if (/^(https?:)?\/\//i.test(href.trim()))
213
+ out.push('rel="noopener nofollow"');
214
+ }
215
+ return out.length ? " " + out.join(" ") : "";
216
+ }
217
+ function sanitizeDeliveryHtml(html) {
218
+ let s = html || "";
219
+ s = s.replace(/<(script|style|iframe|object|embed|form|noscript|template|svg|math)\b[\s\S]*?<\/\1\s*>/gi, "");
220
+ s = s.replace(/<!--[\s\S]*?-->/g, "");
221
+ s = s.replace(/<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b((?:"[^"]*"|'[^']*'|[^>])*)>/g, (_full, slash, tag, attrs) => {
222
+ const t = tag.toLowerCase();
223
+ if (!SANITIZE_ALLOWED_TAGS.has(t))
224
+ return "";
225
+ if (slash)
226
+ return `</${t}>`;
227
+ return `<${t}${sanitizeAttrs(t, attrs)}${SANITIZE_VOID.has(t) ? " /" : ""}>`;
228
+ });
229
+ return s.trim();
230
+ }
231
+ // ── Компоненты (серверные) ──────────────────────────────────────────────────────────────────
232
+ const BODY_CSS = `.seoagent-blog-body img{max-width:100%;height:auto;border-radius:8px;margin:1rem 0}
233
+ .seoagent-blog-body table{width:100%;border-collapse:collapse;margin:1rem 0;font-size:.95em}
234
+ .seoagent-blog-body th,.seoagent-blog-body td{border:1px solid #e5e7eb;padding:.5rem .75rem;text-align:left}
235
+ .seoagent-blog-body th{background:#f8fafc}
236
+ .seoagent-blog-body h2{margin:1.75rem 0 .75rem;font-size:1.5rem;line-height:1.3}
237
+ .seoagent-blog-body h3{margin:1.4rem 0 .6rem;font-size:1.2rem}
238
+ .seoagent-blog-body p{margin:0 0 1rem}
239
+ .seoagent-blog-body a{color:#2563eb}
240
+ .seoagent-blog-body figure{margin:1.25rem 0}
241
+ .seoagent-blog-body figcaption{color:#94a3b8;font-size:.85em;text-align:center;margin-top:.4rem}
242
+ .seoagent-blog-body blockquote{margin:1rem 0;padding:.5rem 1rem;border-left:3px solid #e5e7eb;color:#475569}`;
243
+ // Список статей блога. Сам тянет конфиг+статьи. Пусто/ошибка → null (ничего не ломаем).
244
+ async function SeoBlogIndex(props) {
245
+ const cfg = await fetchBlogConfig();
246
+ if (!cfg)
247
+ return null;
248
+ const locale = props.locale ?? firstLocale(cfg);
249
+ const posts = await fetchPosts(cfg, locale, props.limit ?? 30);
250
+ if (!posts.length)
251
+ return null;
252
+ const basePath = (props.basePath ?? "/blog").replace(/\/+$/, "");
253
+ return ((0, jsx_runtime_1.jsxs)("div", { style: { maxWidth: 1080, margin: "0 auto", padding: "1.5rem" }, children: [props.title ? (0, jsx_runtime_1.jsx)("h1", { style: { fontSize: "2rem", margin: "0 0 1.5rem" }, children: props.title }) : null, (0, jsx_runtime_1.jsx)("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: "1.5rem" }, children: posts.map((p) => ((0, jsx_runtime_1.jsxs)("a", { href: `${basePath}/${p.slug}`, style: { display: "block", textDecoration: "none", color: "inherit", border: "1px solid #e5e7eb", borderRadius: 12, overflow: "hidden" }, children: [p.cover ? (0, jsx_runtime_1.jsx)("img", { src: p.cover, alt: p.title, style: { width: "100%", height: 180, objectFit: "cover", display: "block" } }) : null, (0, jsx_runtime_1.jsxs)("div", { style: { padding: "1rem" }, children: [(0, jsx_runtime_1.jsx)("h2", { style: { fontSize: "1.1rem", margin: "0 0 .5rem", lineHeight: 1.3 }, children: p.title }), p.excerpt ? (0, jsx_runtime_1.jsx)("p", { style: { margin: 0, color: "#64748b", fontSize: ".9rem", lineHeight: 1.5 }, children: p.excerpt }) : null] })] }, p.slug))) })] }));
254
+ }
255
+ // Одна статья. Принимает пост (из seoBlogPost) — так страница может сделать notFound()/метаданные.
256
+ function SeoBlogArticle({ post, locale }) {
257
+ return ((0, jsx_runtime_1.jsxs)("article", { style: { maxWidth: 760, margin: "0 auto", padding: "1.5rem", lineHeight: 1.7 }, children: [(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: BODY_CSS } }), post.cover ? (0, jsx_runtime_1.jsx)("img", { src: post.cover, alt: post.title, style: { width: "100%", borderRadius: 12, marginBottom: "1.5rem" } }) : null, (0, jsx_runtime_1.jsx)("h1", { style: { fontSize: "2.2rem", lineHeight: 1.2, margin: "0 0 1rem" }, children: post.title }), post.date ? (0, jsx_runtime_1.jsx)("p", { style: { color: "#94a3b8", margin: "0 0 1.5rem" }, children: fmtDate(post.date, locale) }) : null, (0, jsx_runtime_1.jsx)("div", { className: "seoagent-blog-body", dangerouslySetInnerHTML: { __html: post.bodyHtml } })] }));
258
+ }
259
+ function fmtDate(d, locale) {
260
+ try {
261
+ return new Date(d).toLocaleDateString(locale || undefined, { year: "numeric", month: "long", day: "numeric" });
262
+ }
263
+ catch {
264
+ return "";
265
+ }
266
+ }
package/dist/client.d.ts CHANGED
@@ -5,6 +5,7 @@ export declare function seoMeta(opts: {
5
5
  fallback?: Metadata;
6
6
  }): Promise<Metadata>;
7
7
  export declare function seoSitemapXml(path: string): Promise<string>;
8
+ export declare function seoMerchantFeedXml(path: string): Promise<string>;
8
9
  export { SITEMAP_XSL } from "./sitemap-xsl";
9
10
  export { seoWrapSitemap } from "./sitemap-build";
10
11
  export declare function seoSitemap(): Promise<MetadataRoute.Sitemap>;
package/dist/client.js CHANGED
@@ -14,6 +14,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.seoWrapSitemap = exports.SITEMAP_XSL = void 0;
15
15
  exports.seoMeta = seoMeta;
16
16
  exports.seoSitemapXml = seoSitemapXml;
17
+ exports.seoMerchantFeedXml = seoMerchantFeedXml;
17
18
  exports.seoSitemap = seoSitemap;
18
19
  exports.seoRobots = seoRobots;
19
20
  exports.seoRobotsTxt = seoRobotsTxt;
@@ -255,6 +256,29 @@ async function seoSitemapXml(path) {
255
256
  return FALLBACK;
256
257
  }
257
258
  }
259
+ // Товарный фид Google Merchant Center (RSS 2.0 + g:-namespace): сырой XML с платформы.
260
+ // Платформа собирает его из закэшированного индекса товаров (быстро, без живого краула).
261
+ // app/seoagent-merchant-feed.xml/route.ts:
262
+ // export async function GET() { return new Response(await seoMerchantFeedXml("/merchant-feed.xml"), {headers:{"content-type":"application/xml"}}) }
263
+ async function seoMerchantFeedXml(path) {
264
+ // Валидный RSS-канал (title/link/description обязательны в RSS 2.0) с пустым списком товаров.
265
+ const FALLBACK = `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">\n<channel>\n <title>Merchant feed</title>\n <link>https://seonum.uz</link>\n <description>Merchant feed temporarily unavailable</description>\n</channel>\n</rss>\n`;
266
+ if (!API || !SITE || !TOKEN)
267
+ return FALLBACK;
268
+ try {
269
+ const r = await fetch(`${API}/api/seo/${SITE}${path}`, {
270
+ headers: { "x-seo-agent-key": TOKEN },
271
+ next: { revalidate: 300, tags: [`seo-${SITE}`] },
272
+ signal: AbortSignal.timeout(8000),
273
+ });
274
+ if (!r.ok)
275
+ return FALLBACK;
276
+ return await r.text();
277
+ }
278
+ catch {
279
+ return FALLBACK;
280
+ }
281
+ }
258
282
  var sitemap_xsl_1 = require("./sitemap-xsl");
259
283
  Object.defineProperty(exports, "SITEMAP_XSL", { enumerable: true, get: function () { return sitemap_xsl_1.SITEMAP_XSL; } });
260
284
  var sitemap_build_1 = require("./sitemap-build");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webnumseoagent/next",
3
- "version": "0.1.24",
3
+ "version": "0.2.0",
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",
@@ -23,6 +23,10 @@
23
23
  "./analytics": {
24
24
  "types": "./dist/analytics.d.ts",
25
25
  "default": "./dist/analytics.js"
26
+ },
27
+ "./blog": {
28
+ "types": "./dist/blog.d.ts",
29
+ "default": "./dist/blog.js"
26
30
  }
27
31
  },
28
32
  "files": [
@@ -30,6 +34,7 @@
30
34
  "client.ts",
31
35
  "jsonld.tsx",
32
36
  "analytics.tsx",
37
+ "blog.tsx",
33
38
  "sitemap-xsl.ts",
34
39
  "sitemap-build.ts",
35
40
  "redirects.ts",