@webnumseoagent/next 0.3.1 → 0.4.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 +104 -1
- package/blog.tsx +84 -33
- package/dist/blog.d.ts +3 -2
- package/dist/blog.js +63 -24
- package/package.json +1 -1
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
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
|
-
|
|
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:
|
|
118
|
-
excerpt: m.excerpt ?
|
|
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
|
-
//
|
|
252
|
-
|
|
261
|
+
// Сетка/карточки списка — через класс + медиазапросы (инлайн-стиль их не умеет): 1→2→3→4 колонки.
|
|
262
|
+
// Цвета/рамки/скругления берутся из --sa-* (авто-тема сайта). Наследует шрифт/цвет текста от layout.
|
|
263
|
+
const GRID_CSS = `.sa-blog-wrap{max-width:1120px;margin:0 auto;padding:var(--sa-blog-top,clamp(5rem,8vw,7rem)) 1.25rem 3rem}
|
|
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
|
-
|
|
255
|
-
const
|
|
256
|
-
const
|
|
257
|
-
|
|
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
|
|
261
|
-
|
|
262
|
-
<
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
{
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
}
|
|
@@ -278,7 +329,7 @@ export async function SeoBlogIndex(props: { locale?: string; basePath?: string;
|
|
|
278
329
|
// Одна статья. Принимает пост (из seoBlogPost) — так страница может сделать notFound()/метаданные.
|
|
279
330
|
export function SeoBlogArticle({ post, locale }: { post: SeoBlogPostFull; locale?: string }) {
|
|
280
331
|
return (
|
|
281
|
-
<article style={{ maxWidth: 760, margin: "0 auto", padding: "1.5rem", lineHeight: 1.7, ...themeStyle(post.theme) } as CSSProperties}>
|
|
332
|
+
<article style={{ maxWidth: 760, margin: "0 auto", padding: "var(--sa-blog-top, clamp(5rem, 8vw, 7rem)) 1.5rem 3rem", lineHeight: 1.7, ...themeStyle(post.theme) } as CSSProperties}>
|
|
282
333
|
<style dangerouslySetInnerHTML={{ __html: BODY_CSS }} />
|
|
283
334
|
{post.cover ? <img src={post.cover} alt={post.title} style={{ width: "100%", borderRadius: "var(--sa-radius, 12px)", marginBottom: "1.5rem" }} /> : null}
|
|
284
335
|
<h1 style={{ fontSize: "2.2rem", lineHeight: 1.2, margin: "0 0 1rem" }}>{post.title}</h1>
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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:
|
|
98
|
-
excerpt: m.excerpt ?
|
|
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,21 +264,47 @@ function themeStyle(theme) {
|
|
|
251
264
|
out["--sa-radius"] = t.radius;
|
|
252
265
|
return out;
|
|
253
266
|
}
|
|
254
|
-
//
|
|
267
|
+
// Сетка/карточки списка — через класс + медиазапросы (инлайн-стиль их не умеет): 1→2→3→4 колонки.
|
|
268
|
+
// Цвета/рамки/скругления берутся из --sa-* (авто-тема сайта). Наследует шрифт/цвет текста от layout.
|
|
269
|
+
const GRID_CSS = `.sa-blog-wrap{max-width:1120px;margin:0 auto;padding:var(--sa-blog-top,clamp(5rem,8vw,7rem)) 1.25rem 3rem}
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
const
|
|
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
|
-
|
|
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 }) {
|
|
268
|
-
return ((0, jsx_runtime_1.jsxs)("article", { style: { maxWidth: 760, margin: "0 auto", padding: "1.5rem", lineHeight: 1.7, ...themeStyle(post.theme) }, 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: "var(--sa-radius, 12px)", 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: "var(--sa-muted, #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 } })] }));
|
|
307
|
+
return ((0, jsx_runtime_1.jsxs)("article", { style: { maxWidth: 760, margin: "0 auto", padding: "var(--sa-blog-top, clamp(5rem, 8vw, 7rem)) 1.5rem 3rem", lineHeight: 1.7, ...themeStyle(post.theme) }, 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: "var(--sa-radius, 12px)", 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: "var(--sa-muted, #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 } })] }));
|
|
269
308
|
}
|
|
270
309
|
function fmtDate(d, locale) {
|
|
271
310
|
try {
|
package/package.json
CHANGED