@webnumseoagent/next 0.3.1 → 0.4.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
@@ -39,6 +39,23 @@ async function anyExistsRel(paths) {
39
39
  for (const p of paths) if (await exists(path.join(root, p))) return true;
40
40
  return false;
41
41
  }
42
+ // Пишет НАШ генерируемый роут: создаёт, если нет; ПЕРЕЗАПИСЫВАЕТ, если файл уже наш (импортит наши
43
+ // blog-компоненты) и содержимое изменилось (напр. старый init-роут без ?show → обновляем на актуальный
44
+ // с пагинацией); НЕ трогает собственный блог сайта (файл без наших импортов).
45
+ async function writeOurRoute(p, content) {
46
+ const cur = await fs.readFile(p, "utf8").catch(() => null);
47
+ if (cur === null) {
48
+ await fs.mkdir(path.dirname(p), { recursive: true });
49
+ await fs.writeFile(p, content);
50
+ ok(path.relative(root, p));
51
+ return;
52
+ }
53
+ if (!/SeoBlogIndex|SeoBlogArticle|seoBlogPost/.test(cur)) { skip(`${path.relative(root, p)} — свой блог сайта`); return; }
54
+ if (cur === content) { skip(path.relative(root, p)); return; }
55
+ await fs.writeFile(p, content);
56
+ ok(`${path.relative(root, p)} (обновлён)`);
57
+ }
58
+
42
59
  // Переименовывает файл в *.bak (не удаляя) — сохраняем чужой robots/sitemap при takeover.
43
60
  async function backupIfExists(p) {
44
61
  if (!(await exists(p))) return false;
@@ -622,16 +639,102 @@ async function takeover() {
622
639
  log("\n\x1b[1mЗатем:\x1b[0m проверь \x1b[36mgit diff\x1b[0m, затем \x1b[36mgit add -A && git commit && git push\x1b[0m. Убедись, что env SEOAGENT_* заданы в Vercel.\n");
623
640
  }
624
641
 
642
+ // Динамическая папка локали в app/ (next-intl обычно [locale]) — чтобы положить блог ПОД layout сайта.
643
+ async function findLocaleDir(appDir) {
644
+ const entries = await fs.readdir(path.join(root, appDir), { withFileTypes: true }).catch(() => []);
645
+ const dyn = entries.filter((e) => e.isDirectory() && /^\[[a-zA-Z]+\]$/.test(e.name)).map((e) => e.name);
646
+ return dyn.find((n) => /^\[(locale|lang|lng|language)\]$/i.test(n)) ?? null;
647
+ }
648
+
649
+ // `blog` — ставит/переставляет страницы блога: под layout сайта (в [locale] у мультиязычных — тогда
650
+ // блог наследует header/footer сайта) либо на корень app/. Роуты: список (?show= → «Показать больше»
651
+ // по 20) + [slug]. Идемпотентно (свои роуты не перезаписывает). Дизайн/шапка/футер — с самого сайта.
652
+ async function blogPage() {
653
+ log("\n\x1b[1m@webnumseoagent/next — страница блога\x1b[0m\n");
654
+ if (!isGitClean() && !args.includes("--force")) {
655
+ console.error(" ✗ Рабочее дерево git не чистое. Закоммить/откатись (или добавь --force).");
656
+ process.exit(1);
657
+ }
658
+ const appDir = await findAppDir();
659
+ if (!appDir) { console.error(" ✗ Не найдена папка app/ или src/app/. Запусти из корня Next.js-проекта."); process.exit(1); }
660
+ ok(`Найден app-каталог: ${appDir}/`);
661
+ const ext = (await exists(path.join(root, "tsconfig.json"))) ? "ts" : "js";
662
+ const jsxExt = ext === "ts" ? "tsx" : "jsx";
663
+ const pkgMode = await exists(path.join(root, "node_modules/@webnumseoagent/next/package.json"));
664
+ if (!pkgMode && !(await exists(path.join(root, "seoagent", "blog.tsx")))) {
665
+ console.error(" ✗ Адаптер не установлен (нет пакета и нет seoagent/blog.tsx). Сначала запусти init.");
666
+ process.exit(1);
667
+ }
668
+ const blogFor = (file) => (pkgMode ? "@webnumseoagent/next/blog" : `${importPath(appDir, file)}/blog`);
669
+
670
+ const localeDir = await findLocaleDir(appDir); // "[locale]" | null
671
+ const under = localeDir ? `${localeDir}/` : "";
672
+ const param = localeDir ? localeDir.slice(1, -1) : null; // "locale"
673
+ if (localeDir) ok(`Мультиязычный сайт (${localeDir}) — блог под layout сайта: ${appDir}/${localeDir}/blog (header/footer сайта).`);
674
+ else ok(`Блог на ${appDir}/blog (наследует корневой layout сайта).`);
675
+
676
+ // Ставим под [locale], но на корне уже есть НАШ голый app/blog → бэкапим его (иначе останется
677
+ // дублирующий /blog без шапки). Трогаем только наши файлы (импортят SeoBlogIndex/seoBlogPost).
678
+ if (localeDir) {
679
+ for (const f of [`blog/page.${jsxExt}`, `blog/[slug]/page.${jsxExt}`]) {
680
+ const p = path.join(root, appDir, f);
681
+ const cur = await fs.readFile(p, "utf8").catch(() => null);
682
+ if (cur && /SeoBlogIndex|SeoBlogArticle|seoBlogPost/.test(cur)) await backupIfExists(p);
683
+ }
684
+ }
685
+
686
+ // Конфликт: свой [иной-сегмент] в blog → свой [slug] не создаём (Next запрещает два динам. имени).
687
+ let blogDynConflict = false;
688
+ const blogDirAbs = path.join(root, appDir, under, "blog");
689
+ if (await exists(blogDirAbs)) {
690
+ const names = await fs.readdir(blogDirAbs).catch(() => []);
691
+ blogDynConflict = names.some((n) => /^\[.*\]$/.test(n) && n !== "[slug]");
692
+ }
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 }");
698
+ const idxBody = param
699
+ ? `<SeoBlogIndex locale={params.${param}} basePath={\`/\${params.${param}}/blog\`} show={Number(searchParams?.show) || 20} />`
700
+ : `<SeoBlogIndex show={Number(searchParams?.show) || 20} />`;
701
+ await writeOurRoute(
702
+ 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
+ );
705
+
706
+ if (blogDynConflict) {
707
+ log(`\n \x1b[1mУ сайта уже есть blog/[иной-сегмент]\x1b[0m — роут статьи /blog/[slug] не создаю. Подключи вручную: \x1b[36mimport { seoBlogPost, SeoBlogArticle } from "${blogFor(`${under}blog/[slug]/page.tsx`)}";\x1b[0m`);
708
+ } else {
709
+ const artSig = ext === "ts"
710
+ ? (param ? `{ params }: { params: { ${param}: string; slug: string } }` : `{ params }: { params: { slug: string } }`)
711
+ : "{ params }";
712
+ const localeArg = param ? `, { locale: params.${param} }` : "";
713
+ const localeProp = param ? ` locale={params.${param}}` : "";
714
+ await writeOurRoute(
715
+ 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
+ );
718
+ }
719
+
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
+ }
722
+
625
723
  if (cmd === "init") {
626
724
  init().catch((e) => { console.error(e); process.exit(1); });
627
725
  } else if (cmd === "takeover") {
628
726
  takeover().catch((e) => { console.error(e); process.exit(1); });
727
+ } else if (cmd === "blog") {
728
+ blogPage().catch((e) => { console.error(e); process.exit(1); });
629
729
  } else {
630
- log("Использование: npx @webnumseoagent/next <init|takeover> [опции]");
730
+ log("Использование: npx @webnumseoagent/next <init|takeover|blog> [опции]");
631
731
  log(" init [--wrap] [--no-blog] [--site <id>] [--token <t>] [--api <url>] [--revalidate-secret <s>]");
632
732
  log(" --wrap — авто-обернуть generateMetadata страниц (иначе печатает сниппеты)");
633
733
  log(" --no-blog — не создавать роуты блога /blog (по умолчанию блог создаётся)");
634
734
  log(" takeover [--robots] [--sitemap] [--force]");
635
735
  log(" передать robots.txt/sitemap.xml под управление платформы (бэкап чужих в *.bak);");
636
736
  log(" без флагов — оба. Требует уже установленного адаптера (init).");
737
+ log(" blog [--force]");
738
+ log(" поставить страницу блога под layout сайта (мультиязычный → /[locale]/blog, с header/footer);");
739
+ log(" список 4-в-ряд + «Показать больше» (по 20). Дизайн — с самого сайта.");
637
740
  }
package/blog.tsx CHANGED
@@ -81,20 +81,27 @@ const base = (cfg: SeoBlogConfig): string => cfg.supabaseUrl.replace(/\/+$/, "")
81
81
  async function fetchPosts(cfg: SeoBlogConfig, locale: string, limit: number): Promise<SeoBlogSummary[]> {
82
82
  const m = cfg.mapping;
83
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
+ };
84
94
  const suf = locale ? `_${locale}` : "";
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
- const rows = await supaGet(cfg, url);
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); }
93
100
  return (rows ?? [])
94
101
  .map((r) => ({
95
102
  slug: S(r[m.id!]),
96
- title: S(r[titleCol]),
97
- excerpt: excerptCol ? S(r[excerptCol]) : "",
103
+ title: S(r[q.titleCol]),
104
+ excerpt: q.excerptCol ? S(r[q.excerptCol]) : "",
98
105
  cover: m.image ? (r[m.image] as string | null) ?? null : null,
99
106
  date: m.publishedAt ? (r[m.publishedAt] as string | null) ?? null : null,
100
107
  }))
@@ -111,11 +118,14 @@ async function fetchPost(cfg: SeoBlogConfig, slug: string, locale: string): Prom
111
118
  const rows = await supaGet(cfg, url);
112
119
  const r = rows && rows[0];
113
120
  if (!r) return null;
114
- const rawBody = S(r[m.body + suf]);
121
+ // Локаль-колонка пуста/отсутствует → берём базовую (оригинал). Так статья не выходит пустой, если
122
+ // перевода на запрошенную локаль нет (доставка тоже кладёт оригинал для непереведённых локалей).
123
+ const val = (colBase: string) => (suf ? S(r[colBase + suf]) : "") || S(r[colBase]);
124
+ const rawBody = val(m.body);
115
125
  return {
116
126
  slug: S(r[m.id]) || slug,
117
- title: S(r[m.title + suf]),
118
- excerpt: m.excerpt ? S(r[m.excerpt + suf]) : "",
127
+ title: val(m.title),
128
+ excerpt: m.excerpt ? val(m.excerpt) : "",
119
129
  cover: m.image ? (r[m.image] as string | null) ?? null : null,
120
130
  date: m.publishedAt ? (r[m.publishedAt] as string | null) ?? null : null,
121
131
  bodyHtml: cfg.format === "minimarkup" ? miniMarkupToHtml(rawBody) : sanitizeDeliveryHtml(rawBody),
@@ -248,29 +258,70 @@ function themeStyle(theme?: SeoBlogTheme): Record<string, string> {
248
258
  return out;
249
259
  }
250
260
 
251
- // Список статей блога. Сам тянет конфиг+статьи. Пусто/ошибка null (ничего не ломаем).
252
- export async function SeoBlogIndex(props: { locale?: string; basePath?: string; limit?: number; title?: string }) {
261
+ // Сетка/карточки списка через класс + медиазапросы (инлайн-стиль их не умеет): 1→2→3→4 колонки.
262
+ // Цвета/рамки/скругления берутся из --sa-* (авто-тема сайта). Наследует шрифт/цвет текста от layout.
263
+ const GRID_CSS = `.sa-blog-wrap{max-width:1120px;margin:0 auto;padding:2rem 1.25rem}
264
+ .sa-blog-h1{font-size:2rem;line-height:1.2;margin:0 0 1.5rem}
265
+ .sa-blog-grid{display:grid;gap:1.5rem;grid-template-columns:1fr}
266
+ @media(min-width:560px){.sa-blog-grid{grid-template-columns:repeat(2,1fr)}}
267
+ @media(min-width:880px){.sa-blog-grid{grid-template-columns:repeat(3,1fr)}}
268
+ @media(min-width:1160px){.sa-blog-grid{grid-template-columns:repeat(4,1fr)}}
269
+ .sa-blog-card{display:flex;flex-direction:column;text-decoration:none;color:inherit;border:1px solid var(--sa-border,#e5e7eb);border-radius:var(--sa-radius,12px);overflow:hidden;background:var(--sa-surface,transparent);transition:transform .15s ease,box-shadow .15s ease}
270
+ .sa-blog-card:hover{transform:translateY(-2px);box-shadow:0 10px 28px rgba(0,0,0,.08)}
271
+ .sa-blog-card img{width:100%;aspect-ratio:16/10;object-fit:cover;display:block}
272
+ .sa-blog-card .sa-cb{padding:1rem;display:flex;flex-direction:column;gap:.4rem}
273
+ .sa-blog-card h2{font-size:1.1rem;line-height:1.35;margin:0}
274
+ .sa-blog-card p{margin:0;color:var(--sa-muted,#64748b);font-size:.9rem;line-height:1.5;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
275
+ .sa-blog-more{display:flex;justify-content:center;margin-top:2rem}
276
+ .sa-blog-more a{display:inline-block;padding:.7rem 1.6rem;border:1px solid var(--sa-border,#e5e7eb);border-radius:var(--sa-radius,10px);text-decoration:none;color:inherit;font-weight:500}
277
+ .sa-blog-more a:hover{background:var(--sa-th-bg,#f8fafc)}
278
+ .sa-blog-empty{color:var(--sa-muted,#64748b)}`;
279
+
280
+ 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
+ const EMPTY_LABEL: Record<string, string> = { ru: "Пока нет статей.", uz: "Hozircha maqolalar yo‘q.", en: "No posts yet.", ar: "لا توجد مقالات بعد." };
283
+ const L = (map: Record<string, string>, locale: string, fb: string) => map[locale] || map[locale?.split("-")[0]] || fb;
284
+
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 }) {
253
289
  const cfg = await fetchBlogConfig();
254
- if (!cfg) return null;
255
- const locale = props.locale ?? firstLocale(cfg);
256
- const posts = await fetchPosts(cfg, locale, props.limit ?? 30);
257
- if (!posts.length) return null;
290
+ const locale = props.locale ?? (cfg ? firstLocale(cfg) : "");
291
+ 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));
258
294
  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);
259
299
  return (
260
- <div style={{ maxWidth: 1080, margin: "0 auto", padding: "1.5rem", ...themeStyle(cfg.theme) } as CSSProperties}>
261
- {props.title ? <h1 style={{ fontSize: "2rem", margin: "0 0 1.5rem" }}>{props.title}</h1> : null}
262
- <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: "1.5rem" }}>
263
- {posts.map((p) => (
264
- <a key={p.slug} href={`${basePath}/${p.slug}`}
265
- style={{ display: "block", textDecoration: "none", color: "inherit", border: "1px solid var(--sa-border, #e5e7eb)", borderRadius: "var(--sa-radius, 12px)", overflow: "hidden", background: "var(--sa-surface, transparent)" }}>
266
- {p.cover ? <img src={p.cover} alt={p.title} style={{ width: "100%", height: 180, objectFit: "cover", display: "block" }} /> : null}
267
- <div style={{ padding: "1rem" }}>
268
- <h2 style={{ fontSize: "1.1rem", margin: "0 0 .5rem", lineHeight: 1.3 }}>{p.title}</h2>
269
- {p.excerpt ? <p style={{ margin: 0, color: "var(--sa-muted, #64748b)", fontSize: ".9rem", lineHeight: 1.5 }}>{p.excerpt}</p> : null}
300
+ <div className="sa-blog-wrap" style={themeStyle(cfg?.theme) as CSSProperties}>
301
+ <style dangerouslySetInnerHTML={{ __html: GRID_CSS }} />
302
+ <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>
270
319
  </div>
271
- </a>
272
- ))}
273
- </div>
320
+ ) : null}
321
+ </>
322
+ ) : (
323
+ <p className="sa-blog-empty">{L(EMPTY_LABEL, locale, "No posts yet.")}</p>
324
+ )}
274
325
  </div>
275
326
  );
276
327
  }
package/dist/blog.d.ts CHANGED
@@ -45,9 +45,10 @@ export declare function seoBlogPost(slug: string, opts?: {
45
45
  export declare function SeoBlogIndex(props: {
46
46
  locale?: string;
47
47
  basePath?: string;
48
- limit?: number;
49
48
  title?: string;
50
- }): Promise<import("react").JSX.Element | null>;
49
+ show?: number;
50
+ perPage?: number;
51
+ }): Promise<import("react").JSX.Element>;
51
52
  export declare function SeoBlogArticle({ post, locale }: {
52
53
  post: SeoBlogPostFull;
53
54
  locale?: string;
package/dist/blog.js CHANGED
@@ -57,22 +57,32 @@ async function fetchPosts(cfg, locale, limit) {
57
57
  const m = cfg.mapping;
58
58
  if (!m.id)
59
59
  return []; // без колонки id/slug ссылки на статьи не построить
60
+ const buildUrl = (suf) => {
61
+ const titleCol = m.title + suf;
62
+ const excerptCol = m.excerpt ? m.excerpt + suf : "";
63
+ const cols = [m.id, titleCol, excerptCol, m.image || "", m.publishedAt || ""].filter(Boolean).join(",");
64
+ let url = `${base(cfg)}/rest/v1/${encodeURIComponent(cfg.table)}?select=${cols}`;
65
+ if (m.published)
66
+ url += `&${m.published}=eq.true`;
67
+ if (m.publishedAt)
68
+ url += `&order=${m.publishedAt}.desc`;
69
+ url += `&limit=${limit}`;
70
+ return { url, titleCol, excerptCol };
71
+ };
60
72
  const suf = locale ? `_${locale}` : "";
61
- const titleCol = m.title + suf;
62
- const excerptCol = m.excerpt ? m.excerpt + suf : "";
63
- const cols = [m.id, titleCol, excerptCol, m.image || "", m.publishedAt || ""].filter(Boolean).join(",");
64
- let url = `${base(cfg)}/rest/v1/${encodeURIComponent(cfg.table)}?select=${cols}`;
65
- if (m.published)
66
- url += `&${m.published}=eq.true`;
67
- if (m.publishedAt)
68
- url += `&order=${m.publishedAt}.desc`;
69
- url += `&limit=${limit}`;
70
- const rows = await supaGet(cfg, url);
73
+ let q = buildUrl(suf);
74
+ let rows = await supaGet(cfg, q.url);
75
+ // Запрошенной локали нет среди колонок доставки (напр. языки блога не совпали с локалями сайта)
76
+ // PostgREST вернул 400 (supaGet=null). Откатываемся на базовые колонки (оригинал), а не показываем пусто.
77
+ if (rows === null && suf) {
78
+ q = buildUrl("");
79
+ rows = await supaGet(cfg, q.url);
80
+ }
71
81
  return (rows ?? [])
72
82
  .map((r) => ({
73
83
  slug: S(r[m.id]),
74
- title: S(r[titleCol]),
75
- excerpt: excerptCol ? S(r[excerptCol]) : "",
84
+ title: S(r[q.titleCol]),
85
+ excerpt: q.excerptCol ? S(r[q.excerptCol]) : "",
76
86
  cover: m.image ? r[m.image] ?? null : null,
77
87
  date: m.publishedAt ? r[m.publishedAt] ?? null : null,
78
88
  }))
@@ -91,11 +101,14 @@ async function fetchPost(cfg, slug, locale) {
91
101
  const r = rows && rows[0];
92
102
  if (!r)
93
103
  return null;
94
- const rawBody = S(r[m.body + suf]);
104
+ // Локаль-колонка пуста/отсутствует → берём базовую (оригинал). Так статья не выходит пустой, если
105
+ // перевода на запрошенную локаль нет (доставка тоже кладёт оригинал для непереведённых локалей).
106
+ const val = (colBase) => (suf ? S(r[colBase + suf]) : "") || S(r[colBase]);
107
+ const rawBody = val(m.body);
95
108
  return {
96
109
  slug: S(r[m.id]) || slug,
97
- title: S(r[m.title + suf]),
98
- excerpt: m.excerpt ? S(r[m.excerpt + suf]) : "",
110
+ title: val(m.title),
111
+ excerpt: m.excerpt ? val(m.excerpt) : "",
99
112
  cover: m.image ? r[m.image] ?? null : null,
100
113
  date: m.publishedAt ? r[m.publishedAt] ?? null : null,
101
114
  bodyHtml: cfg.format === "minimarkup" ? miniMarkupToHtml(rawBody) : sanitizeDeliveryHtml(rawBody),
@@ -251,17 +264,43 @@ function themeStyle(theme) {
251
264
  out["--sa-radius"] = t.radius;
252
265
  return out;
253
266
  }
254
- // Список статей блога. Сам тянет конфиг+статьи. Пусто/ошибка null (ничего не ломаем).
267
+ // Сетка/карточки списка через класс + медиазапросы (инлайн-стиль их не умеет): 1→2→3→4 колонки.
268
+ // Цвета/рамки/скругления берутся из --sa-* (авто-тема сайта). Наследует шрифт/цвет текста от layout.
269
+ const GRID_CSS = `.sa-blog-wrap{max-width:1120px;margin:0 auto;padding:2rem 1.25rem}
270
+ .sa-blog-h1{font-size:2rem;line-height:1.2;margin:0 0 1.5rem}
271
+ .sa-blog-grid{display:grid;gap:1.5rem;grid-template-columns:1fr}
272
+ @media(min-width:560px){.sa-blog-grid{grid-template-columns:repeat(2,1fr)}}
273
+ @media(min-width:880px){.sa-blog-grid{grid-template-columns:repeat(3,1fr)}}
274
+ @media(min-width:1160px){.sa-blog-grid{grid-template-columns:repeat(4,1fr)}}
275
+ .sa-blog-card{display:flex;flex-direction:column;text-decoration:none;color:inherit;border:1px solid var(--sa-border,#e5e7eb);border-radius:var(--sa-radius,12px);overflow:hidden;background:var(--sa-surface,transparent);transition:transform .15s ease,box-shadow .15s ease}
276
+ .sa-blog-card:hover{transform:translateY(-2px);box-shadow:0 10px 28px rgba(0,0,0,.08)}
277
+ .sa-blog-card img{width:100%;aspect-ratio:16/10;object-fit:cover;display:block}
278
+ .sa-blog-card .sa-cb{padding:1rem;display:flex;flex-direction:column;gap:.4rem}
279
+ .sa-blog-card h2{font-size:1.1rem;line-height:1.35;margin:0}
280
+ .sa-blog-card p{margin:0;color:var(--sa-muted,#64748b);font-size:.9rem;line-height:1.5;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
281
+ .sa-blog-more{display:flex;justify-content:center;margin-top:2rem}
282
+ .sa-blog-more a{display:inline-block;padding:.7rem 1.6rem;border:1px solid var(--sa-border,#e5e7eb);border-radius:var(--sa-radius,10px);text-decoration:none;color:inherit;font-weight:500}
283
+ .sa-blog-more a:hover{background:var(--sa-th-bg,#f8fafc)}
284
+ .sa-blog-empty{color:var(--sa-muted,#64748b)}`;
285
+ const BLOG_TITLE = { ru: "Блог", uz: "Blog", en: "Blog", ar: "المدونة", tr: "Blog" };
286
+ const MORE_LABEL = { ru: "Показать больше", uz: "Ko‘proq ko‘rsatish", en: "Show more", ar: "عرض المزيد" };
287
+ const EMPTY_LABEL = { ru: "Пока нет статей.", uz: "Hozircha maqolalar yo‘q.", en: "No posts yet.", ar: "لا توجد مقالات بعد." };
288
+ const L = (map, locale, fb) => map[locale] || map[locale?.split("-")[0]] || fb;
289
+ // Список статей блога: H1 + карточки (по 4 в ряд на десктопе) + «Показать больше» (пагинация по 20
290
+ // через ?show=, серверно — без клиентского JS, дружелюбно к SEO). Всегда рендерит H1 (даже пустой блог),
291
+ // чтобы страница не была пустой. perPage — размер «страницы» (дефолт 20), show — сколько показать сейчас.
255
292
  async function SeoBlogIndex(props) {
256
293
  const cfg = await fetchBlogConfig();
257
- if (!cfg)
258
- return null;
259
- const locale = props.locale ?? firstLocale(cfg);
260
- const posts = await fetchPosts(cfg, locale, props.limit ?? 30);
261
- if (!posts.length)
262
- return null;
294
+ const locale = props.locale ?? (cfg ? firstLocale(cfg) : "");
295
+ const title = props.title ?? L(BLOG_TITLE, locale, "Blog");
296
+ const per = Math.max(1, Math.floor(props.perPage ?? 20));
297
+ const show = Math.max(per, Math.floor(Number(props.show) || per));
263
298
  const basePath = (props.basePath ?? "/blog").replace(/\/+$/, "");
264
- return ((0, jsx_runtime_1.jsxs)("div", { style: { maxWidth: 1080, margin: "0 auto", padding: "1.5rem", ...themeStyle(cfg.theme) }, 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 var(--sa-border, #e5e7eb)", borderRadius: "var(--sa-radius, 12px)", overflow: "hidden", background: "var(--sa-surface, transparent)" }, 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: "var(--sa-muted, #64748b)", fontSize: ".9rem", lineHeight: 1.5 }, children: p.excerpt }) : null] })] }, p.slug))) })] }));
299
+ // Тянем show+1, чтобы понять, есть ли ещё (для кнопки «Показать больше»).
300
+ const posts = cfg ? await fetchPosts(cfg, locale, show + 1) : [];
301
+ const hasMore = posts.length > show;
302
+ const visible = posts.slice(0, show);
303
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "sa-blog-wrap", style: themeStyle(cfg?.theme), children: [(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: GRID_CSS } }), (0, jsx_runtime_1.jsx)("h1", { className: "sa-blog-h1", children: title }), visible.length ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("div", { className: "sa-blog-grid", children: visible.map((p) => ((0, jsx_runtime_1.jsxs)("a", { className: "sa-blog-card", href: `${basePath}/${p.slug}`, children: [p.cover ? (0, jsx_runtime_1.jsx)("img", { src: p.cover, alt: p.title, loading: "lazy" }) : null, (0, jsx_runtime_1.jsxs)("div", { className: "sa-cb", children: [(0, jsx_runtime_1.jsx)("h2", { children: p.title }), p.excerpt ? (0, jsx_runtime_1.jsx)("p", { children: p.excerpt }) : null] })] }, p.slug))) }), hasMore ? ((0, jsx_runtime_1.jsx)("div", { className: "sa-blog-more", children: (0, jsx_runtime_1.jsx)("a", { href: `${basePath}?show=${show + per}`, rel: "nofollow", children: L(MORE_LABEL, locale, "Show more") }) })) : null] })) : ((0, jsx_runtime_1.jsx)("p", { className: "sa-blog-empty", children: L(EMPTY_LABEL, locale, "No posts yet.") }))] }));
265
304
  }
266
305
  // Одна статья. Принимает пост (из seoBlogPost) — так страница может сделать notFound()/метаданные.
267
306
  function SeoBlogArticle({ post, locale }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webnumseoagent/next",
3
- "version": "0.3.1",
3
+ "version": "0.4.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",