@webnumseoagent/next 0.2.2 → 0.3.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 +162 -3
- package/blog.tsx +41 -14
- package/dist/blog.d.ts +10 -0
- package/dist/blog.js +34 -23
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -35,6 +35,19 @@ async function ensureFile(p, content) {
|
|
|
35
35
|
ok(path.relative(root, p));
|
|
36
36
|
return true;
|
|
37
37
|
}
|
|
38
|
+
async function anyExistsRel(paths) {
|
|
39
|
+
for (const p of paths) if (await exists(path.join(root, p))) return true;
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
// Переименовывает файл в *.bak (не удаляя) — сохраняем чужой robots/sitemap при takeover.
|
|
43
|
+
async function backupIfExists(p) {
|
|
44
|
+
if (!(await exists(p))) return false;
|
|
45
|
+
let bak = `${p}.bak`, i = 1;
|
|
46
|
+
while (await exists(bak)) bak = `${p}.bak${i++}`;
|
|
47
|
+
await fs.rename(p, bak);
|
|
48
|
+
ok(`${path.relative(root, p)} → ${path.relative(root, bak)} (бэкап)`);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
38
51
|
|
|
39
52
|
async function findAppDir() {
|
|
40
53
|
for (const c of ["app", "src/app"]) {
|
|
@@ -469,10 +482,156 @@ async function findMetadataPages(dir, acc = []) {
|
|
|
469
482
|
return acc;
|
|
470
483
|
}
|
|
471
484
|
|
|
485
|
+
// takeover — передать robots.txt и/или sitemap.xml под управление платформы, даже если у сайта
|
|
486
|
+
// уже есть свои (статический public/robots.txt|sitemap.xml или app/robots.ts|sitemap.ts). Чужие
|
|
487
|
+
// файлы бэкапим в *.bak и ставим наши динамические роуты (их отдаёт адаптер, контентом рулит платформа).
|
|
488
|
+
// Флаги: --robots и/или --sitemap (без флагов — оба). Требует, чтобы адаптер уже был установлен (init).
|
|
489
|
+
async function takeover() {
|
|
490
|
+
log("\n\x1b[1m@webnumseoagent/next — takeover robots/sitemap\x1b[0m\n");
|
|
491
|
+
if (!isGitClean() && !args.includes("--force")) {
|
|
492
|
+
console.error(" ✗ Рабочее дерево git не чистое. Закоммить/откатись (или добавь --force), чтобы дифф был обозрим.");
|
|
493
|
+
process.exit(1);
|
|
494
|
+
}
|
|
495
|
+
const appDir = await findAppDir();
|
|
496
|
+
if (!appDir) {
|
|
497
|
+
console.error(" ✗ Не найдена папка app/ или src/app/. Запусти из корня Next.js-проекта.");
|
|
498
|
+
process.exit(1);
|
|
499
|
+
}
|
|
500
|
+
ok(`Найден app-каталог: ${appDir}/`);
|
|
501
|
+
const ext = (await exists(path.join(root, "tsconfig.json"))) ? "ts" : "js";
|
|
502
|
+
|
|
503
|
+
// Импорт клиента: пакет установлен → из пакета; иначе — из вендор-папки seoagent/ (её создаёт init).
|
|
504
|
+
const pkgMode = await exists(path.join(root, "node_modules/@webnumseoagent/next/package.json"));
|
|
505
|
+
if (!pkgMode && !(await exists(path.join(root, "seoagent", "client.ts")))) {
|
|
506
|
+
console.error(" ✗ Адаптер не установлен (нет пакета и нет seoagent/client.ts). Сначала запусти init.");
|
|
507
|
+
process.exit(1);
|
|
508
|
+
}
|
|
509
|
+
const clientFor = (file) => (pkgMode ? "@webnumseoagent/next" : `${importPath(appDir, file)}/client`);
|
|
510
|
+
|
|
511
|
+
// Без флагов — оба; иначе только выбранные.
|
|
512
|
+
const only = args.includes("--robots") || args.includes("--sitemap");
|
|
513
|
+
const doRobots = !only || args.includes("--robots");
|
|
514
|
+
const doSitemap = !only || args.includes("--sitemap");
|
|
515
|
+
|
|
516
|
+
const hdr = `{ "content-type": "application/xml; charset=utf-8", "x-seoagent-sitemap": "1" }`;
|
|
517
|
+
const childSig = ext === "ts" ? "_req: Request, { params }: { params: { name: string } }" : "_req, { params }";
|
|
518
|
+
|
|
519
|
+
if (doRobots) {
|
|
520
|
+
if (await anyExistsRel([`${appDir}/robots.txt/route.ts`, `${appDir}/robots.txt/route.js`])) {
|
|
521
|
+
skip("robots (наш роут уже есть)");
|
|
522
|
+
} else {
|
|
523
|
+
// Бэкапим чужой robots: статический public/robots.txt, метаданные app/robots.ts|js И статический
|
|
524
|
+
// app/robots.txt (Next-конвенция). Последний критично отодвинуть: иначе mkdir("app/robots.txt")
|
|
525
|
+
// для нашего роут-каталога упадёт с EEXIST (по этому пути уже лежит ФАЙЛ).
|
|
526
|
+
await backupIfExists(path.join(root, "public", "robots.txt"));
|
|
527
|
+
await backupIfExists(path.join(root, appDir, "robots.ts"));
|
|
528
|
+
await backupIfExists(path.join(root, appDir, "robots.js"));
|
|
529
|
+
await backupIfExists(path.join(root, appDir, "robots.txt"));
|
|
530
|
+
await ensureFile(
|
|
531
|
+
path.join(root, appDir, `robots.txt/route.${ext}`),
|
|
532
|
+
`import { seoRobotsTxt } from "${clientFor("robots.txt/route.ts")}";\nexport const revalidate = 300;\nexport async function GET() {\n return new Response(await seoRobotsTxt(), { headers: { "content-type": "text/plain; charset=utf-8", "x-seoagent-robots": "1" } });\n}\n`,
|
|
533
|
+
);
|
|
534
|
+
ok("robots.txt → под управлением платформы");
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (doSitemap) {
|
|
539
|
+
if (await anyExistsRel([`${appDir}/sitemap.xml/route.ts`, `${appDir}/sitemap.xml/route.js`])) {
|
|
540
|
+
skip("sitemap (наш роут уже есть)");
|
|
541
|
+
} else {
|
|
542
|
+
const xslRoute = () => ensureFile(
|
|
543
|
+
path.join(root, appDir, `sitemap.xsl/route.${ext}`),
|
|
544
|
+
`import { SITEMAP_XSL } from "${clientFor("sitemap.xsl/route.ts")}";\nexport function GET() {\n return new Response(SITEMAP_XSL, { headers: { "content-type": "text/xsl; charset=utf-8" } });\n}\n`,
|
|
545
|
+
);
|
|
546
|
+
// Конфликт: у сайта уже есть app/sitemap/[иной-сегмент] — тогда свой [name] не создаём.
|
|
547
|
+
let dynConflict = false;
|
|
548
|
+
const smDir = path.join(root, appDir, "sitemap");
|
|
549
|
+
if (await exists(smDir)) {
|
|
550
|
+
const names = await fs.readdir(smDir).catch(() => []);
|
|
551
|
+
dynConflict = names.some((n) => /^\[.*\]$/.test(n) && n !== "[name]");
|
|
552
|
+
}
|
|
553
|
+
// Роуты-обёртки (зовут переименованную карту сайта sitemap.source + добавляют стиль/разбивку).
|
|
554
|
+
const genWrapRoutes = async () => {
|
|
555
|
+
await ensureFile(
|
|
556
|
+
path.join(root, appDir, `sitemap.xml/route.${ext}`),
|
|
557
|
+
`import original from "../sitemap.source";\nimport { seoWrapSitemap } from "${clientFor("sitemap.xml/route.ts")}";\nexport const revalidate = 300;\nexport async function GET() {\n return new Response(await seoWrapSitemap(original, "${dynConflict ? "flat" : "index"}"), { headers: ${hdr} });\n}\n`,
|
|
558
|
+
);
|
|
559
|
+
if (!dynConflict)
|
|
560
|
+
await ensureFile(
|
|
561
|
+
path.join(root, appDir, `sitemap/[name]/route.${ext}`),
|
|
562
|
+
`import original from "../../sitemap.source";\nimport { seoWrapSitemap } from "${clientFor("sitemap/[name]/route.ts")}";\nexport const revalidate = 300;\nexport async function GET(${childSig}) {\n return new Response(await seoWrapSitemap(original, "child", params.name), { headers: ${hdr} });\n}\n`,
|
|
563
|
+
);
|
|
564
|
+
await xslRoute();
|
|
565
|
+
};
|
|
566
|
+
// Роуты-генерации (карту собирает платформа).
|
|
567
|
+
const genGenerateRoutes = async () => {
|
|
568
|
+
await ensureFile(
|
|
569
|
+
path.join(root, appDir, `sitemap.xml/route.${ext}`),
|
|
570
|
+
`import { seoSitemapXml } from "${clientFor("sitemap.xml/route.ts")}";\nexport const revalidate = 300;\nexport async function GET() {\n return new Response(await seoSitemapXml("/sitemap.xml"), { headers: ${hdr} });\n}\n`,
|
|
571
|
+
);
|
|
572
|
+
if (!dynConflict)
|
|
573
|
+
await ensureFile(
|
|
574
|
+
path.join(root, appDir, `sitemap/[name]/route.${ext}`),
|
|
575
|
+
`import { seoSitemapXml } from "${clientFor("sitemap/[name]/route.ts")}";\nexport const revalidate = 300;\nexport async function GET(${childSig}) {\n return new Response(await seoSitemapXml("/sitemap/" + params.name), { headers: ${hdr} });\n}\n`,
|
|
576
|
+
);
|
|
577
|
+
await xslRoute();
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// Возобновление прерванной обёртки: карта уже переименована в sitemap.source.* (а роутов ещё нет) —
|
|
581
|
+
// просто доделываем wrap-роуты. Иначе повторный запуск ушёл бы в «генерацию» и осиротил бы URL сайта.
|
|
582
|
+
let sourceExt = null;
|
|
583
|
+
for (const e of ["ts", "js", "tsx", "jsx"]) {
|
|
584
|
+
if (await exists(path.join(root, appDir, `sitemap.source.${e}`))) { sourceExt = e; break; }
|
|
585
|
+
}
|
|
586
|
+
if (sourceExt) {
|
|
587
|
+
await genWrapRoutes();
|
|
588
|
+
ok("sitemap.xml → под управлением платформы (обёртка — докрутка после прерывания)");
|
|
589
|
+
} else {
|
|
590
|
+
// Динамическая карта сайта (app/sitemap.ts|js) со СВОИМИ URL: не теряем её — оборачиваем.
|
|
591
|
+
// Только стандартный default-export без generateSitemaps.
|
|
592
|
+
let metaSitemap = null;
|
|
593
|
+
for (const e of ["ts", "js", "tsx", "jsx"]) {
|
|
594
|
+
const p = path.join(root, appDir, `sitemap.${e}`);
|
|
595
|
+
if (await exists(p)) { metaSitemap = { p, e }; break; }
|
|
596
|
+
}
|
|
597
|
+
let wrap = false;
|
|
598
|
+
if (metaSitemap) {
|
|
599
|
+
const code = await fs.readFile(metaSitemap.p, "utf8");
|
|
600
|
+
wrap = !/\bgenerateSitemaps\b/.test(code) &&
|
|
601
|
+
(/export\s+default\s+(async\s+)?function/.test(code) || /export\s+default\s+\w/.test(code));
|
|
602
|
+
}
|
|
603
|
+
// Чужую статическую карту (public/sitemap*.xml и статический app/sitemap.xml) бэкапим в любом
|
|
604
|
+
// случае — иначе она перекроет наш роут; app/sitemap.xml критично отодвинуть (иначе mkdir EEXIST).
|
|
605
|
+
await backupIfExists(path.join(root, "public", "sitemap.xml"));
|
|
606
|
+
await backupIfExists(path.join(root, "public", "sitemap_index.xml"));
|
|
607
|
+
await backupIfExists(path.join(root, appDir, "sitemap.xml"));
|
|
608
|
+
if (wrap) {
|
|
609
|
+
await fs.rename(metaSitemap.p, path.join(root, appDir, `sitemap.source.${metaSitemap.e}`));
|
|
610
|
+
ok(`${appDir}/sitemap.${metaSitemap.e} → ${appDir}/sitemap.source.${metaSitemap.e} (оборачиваем существующую карту)`);
|
|
611
|
+
await genWrapRoutes();
|
|
612
|
+
ok("sitemap.xml → под управлением платформы (обёртка карты сайта)");
|
|
613
|
+
} else {
|
|
614
|
+
if (metaSitemap) await backupIfExists(metaSitemap.p); // нестандартный app/sitemap.* (напр. generateSitemaps)
|
|
615
|
+
await genGenerateRoutes();
|
|
616
|
+
ok("sitemap.xml → под управлением платформы (генерация с платформы)");
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
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
|
+
}
|
|
624
|
+
|
|
472
625
|
if (cmd === "init") {
|
|
473
626
|
init().catch((e) => { console.error(e); process.exit(1); });
|
|
627
|
+
} else if (cmd === "takeover") {
|
|
628
|
+
takeover().catch((e) => { console.error(e); process.exit(1); });
|
|
474
629
|
} else {
|
|
475
|
-
log("Использование: npx @webnumseoagent/next init
|
|
476
|
-
log(" --wrap
|
|
477
|
-
log("
|
|
630
|
+
log("Использование: npx @webnumseoagent/next <init|takeover> [опции]");
|
|
631
|
+
log(" init [--wrap] [--no-blog] [--site <id>] [--token <t>] [--api <url>] [--revalidate-secret <s>]");
|
|
632
|
+
log(" --wrap — авто-обернуть generateMetadata страниц (иначе печатает сниппеты)");
|
|
633
|
+
log(" --no-blog — не создавать роуты блога /blog (по умолчанию блог создаётся)");
|
|
634
|
+
log(" takeover [--robots] [--sitemap] [--force]");
|
|
635
|
+
log(" передать robots.txt/sitemap.xml под управление платформы (бэкап чужих в *.bak);");
|
|
636
|
+
log(" без флагов — оба. Требует уже установленного адаптера (init).");
|
|
478
637
|
}
|
package/blog.tsx
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// app/blog/[slug]/page.tsx: const post = await seoBlogPost(params.slug); if(!post) notFound();
|
|
15
15
|
// return <SeoBlogArticle post={post} />
|
|
16
16
|
|
|
17
|
+
import type { CSSProperties } from "react";
|
|
18
|
+
|
|
17
19
|
const API = process.env.SEOAGENT_API_BASE ?? "";
|
|
18
20
|
const SITE = process.env.SEOAGENT_SITE_ID ?? "";
|
|
19
21
|
const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
|
|
@@ -23,12 +25,16 @@ export interface SeoBlogMapping {
|
|
|
23
25
|
title: string; excerpt?: string; body: string;
|
|
24
26
|
id?: string; image?: string; published?: string; publishedAt?: string;
|
|
25
27
|
}
|
|
28
|
+
// Тема блога от платформы (config-API): цвета/скругления как ВАЛИДНЫЕ CSS-значения, которые пакет
|
|
29
|
+
// кладёт в свои переменные --sa-*. Сейчас платформа шлёт только accent (бренд-цвет владельца);
|
|
30
|
+
// border/surface/muted/radius зарезервированы под будущий ИИ-профиль дизайна сайта.
|
|
31
|
+
export interface SeoBlogTheme { accent?: string; border?: string; muted?: string; surface?: string; thBg?: string; radius?: string }
|
|
26
32
|
export interface SeoBlogConfig {
|
|
27
33
|
supabaseUrl: string; anonKey: string; table: string;
|
|
28
|
-
format: "html" | "minimarkup"; locales: string[]; mapping: SeoBlogMapping;
|
|
34
|
+
format: "html" | "minimarkup"; locales: string[]; mapping: SeoBlogMapping; theme?: SeoBlogTheme;
|
|
29
35
|
}
|
|
30
36
|
export interface SeoBlogSummary { slug: string; title: string; excerpt: string; cover: string | null; date: string | null }
|
|
31
|
-
export interface SeoBlogPostFull extends SeoBlogSummary { bodyHtml: string }
|
|
37
|
+
export interface SeoBlogPostFull extends SeoBlogSummary { bodyHtml: string; theme?: SeoBlogTheme }
|
|
32
38
|
|
|
33
39
|
function firstLocale(cfg: SeoBlogConfig): string {
|
|
34
40
|
return (cfg.locales ?? []).filter(Boolean)[0] ?? "";
|
|
@@ -113,6 +119,7 @@ async function fetchPost(cfg: SeoBlogConfig, slug: string, locale: string): Prom
|
|
|
113
119
|
cover: m.image ? (r[m.image] as string | null) ?? null : null,
|
|
114
120
|
date: m.publishedAt ? (r[m.publishedAt] as string | null) ?? null : null,
|
|
115
121
|
bodyHtml: cfg.format === "minimarkup" ? miniMarkupToHtml(rawBody) : sanitizeDeliveryHtml(rawBody),
|
|
122
|
+
theme: cfg.theme, // тему тащим с постом — чтобы SeoBlogArticle применил её без отдельного запроса
|
|
116
123
|
};
|
|
117
124
|
}
|
|
118
125
|
|
|
@@ -209,17 +216,37 @@ function sanitizeDeliveryHtml(html: string): string {
|
|
|
209
216
|
}
|
|
210
217
|
|
|
211
218
|
// ── Компоненты (серверные) ──────────────────────────────────────────────────────────────────
|
|
212
|
-
|
|
219
|
+
// Цвета/рамки/скругления — через СОБСТВЕННЫЕ переменные пакета (--sa-*) с нейтральным дефолтом.
|
|
220
|
+
// НЕ ссылаемся на токены сайта напрямую: shadcn/Tailwind хранят их как сырые HSL-компоненты
|
|
221
|
+
// (--primary: 222 47% 11%), в color/border это невалидно и ломает вид. Реальные цвета сайта
|
|
222
|
+
// проставит платформа: config-API отдаст профиль, а хост-обёртка выставит --sa-* валидными цветами.
|
|
223
|
+
// Шрифт и базовый цвет текста наследуются от layout сайта (color:inherit, без font-family).
|
|
224
|
+
const BODY_CSS = `.seoagent-blog-body{color:inherit}
|
|
225
|
+
.seoagent-blog-body img{max-width:100%;height:auto;border-radius:var(--sa-radius,8px);margin:1rem 0}
|
|
213
226
|
.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
|
|
215
|
-
.seoagent-blog-body th{background
|
|
227
|
+
.seoagent-blog-body th,.seoagent-blog-body td{border:1px solid var(--sa-border,#e5e7eb);padding:.5rem .75rem;text-align:left}
|
|
228
|
+
.seoagent-blog-body th{background:var(--sa-th-bg,#f8fafc)}
|
|
216
229
|
.seoagent-blog-body h2{margin:1.75rem 0 .75rem;font-size:1.5rem;line-height:1.3}
|
|
217
230
|
.seoagent-blog-body h3{margin:1.4rem 0 .6rem;font-size:1.2rem}
|
|
218
231
|
.seoagent-blog-body p{margin:0 0 1rem}
|
|
219
|
-
.seoagent-blog-body a{color
|
|
232
|
+
.seoagent-blog-body a{color:var(--sa-accent,#2563eb)}
|
|
220
233
|
.seoagent-blog-body figure{margin:1.25rem 0}
|
|
221
|
-
.seoagent-blog-body figcaption{color
|
|
222
|
-
.seoagent-blog-body blockquote{margin:1rem 0;padding:.5rem 1rem;border-left:3px solid
|
|
234
|
+
.seoagent-blog-body figcaption{color:var(--sa-muted,#94a3b8);font-size:.85em;text-align:center;margin-top:.4rem}
|
|
235
|
+
.seoagent-blog-body blockquote{margin:1rem 0;padding:.5rem 1rem;border-left:3px solid var(--sa-border,#e5e7eb);color:var(--sa-muted,#475569)}`;
|
|
236
|
+
|
|
237
|
+
// Тема → CSS-переменные пакета (--sa-*). Кладём на корень блока — они каскадируются вниз к
|
|
238
|
+
// ссылкам/карточкам/телу. Отдаём только заданные ключи (пустые не трогаем → останется fallback).
|
|
239
|
+
function themeStyle(theme?: SeoBlogTheme): Record<string, string> {
|
|
240
|
+
const t = theme ?? {};
|
|
241
|
+
const out: Record<string, string> = {};
|
|
242
|
+
if (t.accent) out["--sa-accent"] = t.accent;
|
|
243
|
+
if (t.border) out["--sa-border"] = t.border;
|
|
244
|
+
if (t.muted) out["--sa-muted"] = t.muted;
|
|
245
|
+
if (t.surface) out["--sa-surface"] = t.surface;
|
|
246
|
+
if (t.thBg) out["--sa-th-bg"] = t.thBg;
|
|
247
|
+
if (t.radius) out["--sa-radius"] = t.radius;
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
223
250
|
|
|
224
251
|
// Список статей блога. Сам тянет конфиг+статьи. Пусто/ошибка → null (ничего не ломаем).
|
|
225
252
|
export async function SeoBlogIndex(props: { locale?: string; basePath?: string; limit?: number; title?: string }) {
|
|
@@ -230,16 +257,16 @@ export async function SeoBlogIndex(props: { locale?: string; basePath?: string;
|
|
|
230
257
|
if (!posts.length) return null;
|
|
231
258
|
const basePath = (props.basePath ?? "/blog").replace(/\/+$/, "");
|
|
232
259
|
return (
|
|
233
|
-
<div style={{ maxWidth: 1080, margin: "0 auto", padding: "1.5rem" }}>
|
|
260
|
+
<div style={{ maxWidth: 1080, margin: "0 auto", padding: "1.5rem", ...themeStyle(cfg.theme) } as CSSProperties}>
|
|
234
261
|
{props.title ? <h1 style={{ fontSize: "2rem", margin: "0 0 1.5rem" }}>{props.title}</h1> : null}
|
|
235
262
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: "1.5rem" }}>
|
|
236
263
|
{posts.map((p) => (
|
|
237
264
|
<a key={p.slug} href={`${basePath}/${p.slug}`}
|
|
238
|
-
style={{ display: "block", textDecoration: "none", color: "inherit", border: "1px solid #e5e7eb", borderRadius:
|
|
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)" }}>
|
|
239
266
|
{p.cover ? <img src={p.cover} alt={p.title} style={{ width: "100%", height: 180, objectFit: "cover", display: "block" }} /> : null}
|
|
240
267
|
<div style={{ padding: "1rem" }}>
|
|
241
268
|
<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}
|
|
269
|
+
{p.excerpt ? <p style={{ margin: 0, color: "var(--sa-muted, #64748b)", fontSize: ".9rem", lineHeight: 1.5 }}>{p.excerpt}</p> : null}
|
|
243
270
|
</div>
|
|
244
271
|
</a>
|
|
245
272
|
))}
|
|
@@ -251,11 +278,11 @@ export async function SeoBlogIndex(props: { locale?: string; basePath?: string;
|
|
|
251
278
|
// Одна статья. Принимает пост (из seoBlogPost) — так страница может сделать notFound()/метаданные.
|
|
252
279
|
export function SeoBlogArticle({ post, locale }: { post: SeoBlogPostFull; locale?: string }) {
|
|
253
280
|
return (
|
|
254
|
-
<article style={{ maxWidth: 760, margin: "0 auto", padding: "1.5rem", lineHeight: 1.7 }}>
|
|
281
|
+
<article style={{ maxWidth: 760, margin: "0 auto", padding: "1.5rem", lineHeight: 1.7, ...themeStyle(post.theme) } as CSSProperties}>
|
|
255
282
|
<style dangerouslySetInnerHTML={{ __html: BODY_CSS }} />
|
|
256
|
-
{post.cover ? <img src={post.cover} alt={post.title} style={{ width: "100%", borderRadius:
|
|
283
|
+
{post.cover ? <img src={post.cover} alt={post.title} style={{ width: "100%", borderRadius: "var(--sa-radius, 12px)", marginBottom: "1.5rem" }} /> : null}
|
|
257
284
|
<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}
|
|
285
|
+
{post.date ? <p style={{ color: "var(--sa-muted, #94a3b8)", margin: "0 0 1.5rem" }}>{fmtDate(post.date, locale)}</p> : null}
|
|
259
286
|
{/* Тело уже санитайзено при доставке (Фаза 3.1b) — рендерим как HTML. */}
|
|
260
287
|
<div className="seoagent-blog-body" dangerouslySetInnerHTML={{ __html: post.bodyHtml }} />
|
|
261
288
|
</article>
|
package/dist/blog.d.ts
CHANGED
|
@@ -7,6 +7,14 @@ export interface SeoBlogMapping {
|
|
|
7
7
|
published?: string;
|
|
8
8
|
publishedAt?: string;
|
|
9
9
|
}
|
|
10
|
+
export interface SeoBlogTheme {
|
|
11
|
+
accent?: string;
|
|
12
|
+
border?: string;
|
|
13
|
+
muted?: string;
|
|
14
|
+
surface?: string;
|
|
15
|
+
thBg?: string;
|
|
16
|
+
radius?: string;
|
|
17
|
+
}
|
|
10
18
|
export interface SeoBlogConfig {
|
|
11
19
|
supabaseUrl: string;
|
|
12
20
|
anonKey: string;
|
|
@@ -14,6 +22,7 @@ export interface SeoBlogConfig {
|
|
|
14
22
|
format: "html" | "minimarkup";
|
|
15
23
|
locales: string[];
|
|
16
24
|
mapping: SeoBlogMapping;
|
|
25
|
+
theme?: SeoBlogTheme;
|
|
17
26
|
}
|
|
18
27
|
export interface SeoBlogSummary {
|
|
19
28
|
slug: string;
|
|
@@ -24,6 +33,7 @@ export interface SeoBlogSummary {
|
|
|
24
33
|
}
|
|
25
34
|
export interface SeoBlogPostFull extends SeoBlogSummary {
|
|
26
35
|
bodyHtml: string;
|
|
36
|
+
theme?: SeoBlogTheme;
|
|
27
37
|
}
|
|
28
38
|
export declare function seoBlogList(opts?: {
|
|
29
39
|
locale?: string;
|
package/dist/blog.js
CHANGED
|
@@ -5,21 +5,6 @@ exports.seoBlogPost = seoBlogPost;
|
|
|
5
5
|
exports.SeoBlogIndex = SeoBlogIndex;
|
|
6
6
|
exports.SeoBlogArticle = SeoBlogArticle;
|
|
7
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
8
|
const API = process.env.SEOAGENT_API_BASE ?? "";
|
|
24
9
|
const SITE = process.env.SEOAGENT_SITE_ID ?? "";
|
|
25
10
|
const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
|
|
@@ -114,6 +99,7 @@ async function fetchPost(cfg, slug, locale) {
|
|
|
114
99
|
cover: m.image ? r[m.image] ?? null : null,
|
|
115
100
|
date: m.publishedAt ? r[m.publishedAt] ?? null : null,
|
|
116
101
|
bodyHtml: cfg.format === "minimarkup" ? miniMarkupToHtml(rawBody) : sanitizeDeliveryHtml(rawBody),
|
|
102
|
+
theme: cfg.theme, // тему тащим с постом — чтобы SeoBlogArticle применил её без отдельного запроса
|
|
117
103
|
};
|
|
118
104
|
}
|
|
119
105
|
// ── Публичные data-функции (для generateMetadata / generateStaticParams) ──
|
|
@@ -229,17 +215,42 @@ function sanitizeDeliveryHtml(html) {
|
|
|
229
215
|
return s.trim();
|
|
230
216
|
}
|
|
231
217
|
// ── Компоненты (серверные) ──────────────────────────────────────────────────────────────────
|
|
232
|
-
|
|
218
|
+
// Цвета/рамки/скругления — через СОБСТВЕННЫЕ переменные пакета (--sa-*) с нейтральным дефолтом.
|
|
219
|
+
// НЕ ссылаемся на токены сайта напрямую: shadcn/Tailwind хранят их как сырые HSL-компоненты
|
|
220
|
+
// (--primary: 222 47% 11%), в color/border это невалидно и ломает вид. Реальные цвета сайта
|
|
221
|
+
// проставит платформа: config-API отдаст профиль, а хост-обёртка выставит --sa-* валидными цветами.
|
|
222
|
+
// Шрифт и базовый цвет текста наследуются от layout сайта (color:inherit, без font-family).
|
|
223
|
+
const BODY_CSS = `.seoagent-blog-body{color:inherit}
|
|
224
|
+
.seoagent-blog-body img{max-width:100%;height:auto;border-radius:var(--sa-radius,8px);margin:1rem 0}
|
|
233
225
|
.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
|
|
235
|
-
.seoagent-blog-body th{background
|
|
226
|
+
.seoagent-blog-body th,.seoagent-blog-body td{border:1px solid var(--sa-border,#e5e7eb);padding:.5rem .75rem;text-align:left}
|
|
227
|
+
.seoagent-blog-body th{background:var(--sa-th-bg,#f8fafc)}
|
|
236
228
|
.seoagent-blog-body h2{margin:1.75rem 0 .75rem;font-size:1.5rem;line-height:1.3}
|
|
237
229
|
.seoagent-blog-body h3{margin:1.4rem 0 .6rem;font-size:1.2rem}
|
|
238
230
|
.seoagent-blog-body p{margin:0 0 1rem}
|
|
239
|
-
.seoagent-blog-body a{color
|
|
231
|
+
.seoagent-blog-body a{color:var(--sa-accent,#2563eb)}
|
|
240
232
|
.seoagent-blog-body figure{margin:1.25rem 0}
|
|
241
|
-
.seoagent-blog-body figcaption{color
|
|
242
|
-
.seoagent-blog-body blockquote{margin:1rem 0;padding:.5rem 1rem;border-left:3px solid
|
|
233
|
+
.seoagent-blog-body figcaption{color:var(--sa-muted,#94a3b8);font-size:.85em;text-align:center;margin-top:.4rem}
|
|
234
|
+
.seoagent-blog-body blockquote{margin:1rem 0;padding:.5rem 1rem;border-left:3px solid var(--sa-border,#e5e7eb);color:var(--sa-muted,#475569)}`;
|
|
235
|
+
// Тема → CSS-переменные пакета (--sa-*). Кладём на корень блока — они каскадируются вниз к
|
|
236
|
+
// ссылкам/карточкам/телу. Отдаём только заданные ключи (пустые не трогаем → останется fallback).
|
|
237
|
+
function themeStyle(theme) {
|
|
238
|
+
const t = theme ?? {};
|
|
239
|
+
const out = {};
|
|
240
|
+
if (t.accent)
|
|
241
|
+
out["--sa-accent"] = t.accent;
|
|
242
|
+
if (t.border)
|
|
243
|
+
out["--sa-border"] = t.border;
|
|
244
|
+
if (t.muted)
|
|
245
|
+
out["--sa-muted"] = t.muted;
|
|
246
|
+
if (t.surface)
|
|
247
|
+
out["--sa-surface"] = t.surface;
|
|
248
|
+
if (t.thBg)
|
|
249
|
+
out["--sa-th-bg"] = t.thBg;
|
|
250
|
+
if (t.radius)
|
|
251
|
+
out["--sa-radius"] = t.radius;
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
243
254
|
// Список статей блога. Сам тянет конфиг+статьи. Пусто/ошибка → null (ничего не ломаем).
|
|
244
255
|
async function SeoBlogIndex(props) {
|
|
245
256
|
const cfg = await fetchBlogConfig();
|
|
@@ -250,11 +261,11 @@ async function SeoBlogIndex(props) {
|
|
|
250
261
|
if (!posts.length)
|
|
251
262
|
return null;
|
|
252
263
|
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:
|
|
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))) })] }));
|
|
254
265
|
}
|
|
255
266
|
// Одна статья. Принимает пост (из seoBlogPost) — так страница может сделать notFound()/метаданные.
|
|
256
267
|
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:
|
|
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 } })] }));
|
|
258
269
|
}
|
|
259
270
|
function fmtDate(d, locale) {
|
|
260
271
|
try {
|
package/package.json
CHANGED