@webnumseoagent/next 0.1.25 → 0.2.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/bin/cli.mjs CHANGED
@@ -101,7 +101,7 @@ async function init() {
101
101
  // Вендор-файлы адаптера — генерируемые (их не правят руками): при повторном init ОБНОВЛЯЕМ,
102
102
  // если содержимое изменилось. Иначе апгрейд оставит старый client.ts без новых экспортов
103
103
  // (напр. seoMerchantFeedXml), а свежесозданный роут импортнёт несуществующий символ → build падёт.
104
- for (const f of ["client.ts", "jsonld.tsx", "sitemap-xsl.ts", "sitemap-build.ts", "redirects.ts"]) {
104
+ for (const f of ["client.ts", "jsonld.tsx", "sitemap-xsl.ts", "sitemap-build.ts", "redirects.ts", "blog.tsx"]) {
105
105
  const dest = path.join(vendor, f);
106
106
  const src = await fs.readFile(path.join(pkgRoot, f));
107
107
  const cur = await fs.readFile(dest).catch(() => null);
@@ -117,6 +117,7 @@ async function init() {
117
117
  // Импорт клиента/jsonld для файла на нужной глубине (в vendor-режиме путь относительный).
118
118
  const clientFor = (file) => (pkgMode ? "@webnumseoagent/next" : `${importPath(appDir, file)}/client`);
119
119
  const jsonldFor = (file) => (pkgMode ? "@webnumseoagent/next/jsonld" : `${importPath(appDir, file)}/jsonld`);
120
+ const blogFor = (file) => (pkgMode ? "@webnumseoagent/next/blog" : `${importPath(appDir, file)}/blog`);
120
121
 
121
122
  // Не создаём sitemap/robots, если у сайта они УЖЕ есть (public/, отдельный роут и т.п.) —
122
123
  // чтобы не перекрыть существующую карту сайта пустой.
@@ -368,6 +369,37 @@ async function init() {
368
369
  if (!jsonLdMounted) {
369
370
  log(`\n И смонтируй JSON-LD в layout (внутри <body>):\n \x1b[36mimport { SeoJsonLd } from "${importJsonld}";\n <SeoJsonLd route="/" />\x1b[0m`);
370
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
+
371
403
  log("\n\x1b[1mЗатем:\x1b[0m задай эти же env-переменные в Vercel (Settings → Environment Variables) и сделай git push.\n");
372
404
  }
373
405
 
@@ -420,6 +452,10 @@ async function findMetadataPages(dir, acc = []) {
420
452
  await findMetadataPages(p, acc);
421
453
  } else if (/\.(tsx|ts|jsx|js)$/.test(e.name) && /page\.|layout\./.test(e.name)) {
422
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;
423
459
  // Собираем и функции generateMetadata, и статический `export const metadata`.
424
460
  if (/generateMetadata/.test(txt) || /export\s+const\s+metadata\b/.test(txt)) acc.push(p);
425
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
@@ -260,7 +260,7 @@ export async function seoSitemapXml(path: string): Promise<string> {
260
260
  // export async function GET() { return new Response(await seoMerchantFeedXml("/merchant-feed.xml"), {headers:{"content-type":"application/xml"}}) }
261
261
  export async function seoMerchantFeedXml(path: string): Promise<string> {
262
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`;
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></link>\n <description>Merchant feed temporarily unavailable</description>\n</channel>\n</rss>\n`;
264
264
  if (!API || !SITE || !TOKEN) return FALLBACK;
265
265
  try {
266
266
  const r = await fetch(`${API}/api/seo/${SITE}${path}`, {
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.js CHANGED
@@ -262,7 +262,7 @@ async function seoSitemapXml(path) {
262
262
  // export async function GET() { return new Response(await seoMerchantFeedXml("/merchant-feed.xml"), {headers:{"content-type":"application/xml"}}) }
263
263
  async function seoMerchantFeedXml(path) {
264
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`;
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></link>\n <description>Merchant feed temporarily unavailable</description>\n</channel>\n</rss>\n`;
266
266
  if (!API || !SITE || !TOKEN)
267
267
  return FALLBACK;
268
268
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webnumseoagent/next",
3
- "version": "0.1.25",
3
+ "version": "0.2.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",
@@ -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",