@webnumseoagent/next 0.7.1 → 0.7.3
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/README.md +4 -0
- package/bin/cli.mjs +68 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,6 +105,10 @@ Vercel и `git push`. **Проверь `git diff`** перед коммитом
|
|
|
105
105
|
контент = git-коммит → редеплой пересобирает страницы.
|
|
106
106
|
- Тема оформления (фирменный цвет) и канонический URL-префикс берутся из `content/blog/.blogfs.json`.
|
|
107
107
|
- Sitemap блога добавляется автоматически (раздел `sitemap/blog.xml`).
|
|
108
|
+
- `init`/`blog` **сами** прописывают в `next.config` `outputFileTracingIncludes` для `content/blog` —
|
|
109
|
+
без этого на Vercel файлы блога не попадают в serverless-бандл, и рантайм-чтение (ISR-ревалидация
|
|
110
|
+
страниц + route-handler карты сайта) находит пусто → блог исчезает с сайта и из sitemap. Идемпотентно;
|
|
111
|
+
запускается и на авто-обновлении адаптера, поэтому сайт чинится сам, без правок разработчика.
|
|
108
112
|
|
|
109
113
|
## Гарантии
|
|
110
114
|
- **Fail-safe:** при недоступности платформы адаптер возвращает твои дефолты и
|
package/bin/cli.mjs
CHANGED
|
@@ -95,6 +95,54 @@ async function detectLocales() {
|
|
|
95
95
|
return [];
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// Вставляет ключ в объектный литерал конфига Next (после его открывающей "{"). Возвращает
|
|
99
|
+
// новый текст или null, если знакомую форму конфига не нашли (тогда печатаем ручную инструкцию).
|
|
100
|
+
function injectIntoConfigObject(src, insert) {
|
|
101
|
+
const patterns = [
|
|
102
|
+
/(\bconst\s+nextConfig\b[^=]*=\s*)\{/,
|
|
103
|
+
/(\bconst\s+config\b[^=]*=\s*)\{/,
|
|
104
|
+
/(\bmodule\.exports\s*=\s*)\{/,
|
|
105
|
+
/(\bexport\s+default\s+)\{/,
|
|
106
|
+
];
|
|
107
|
+
for (const re of patterns) {
|
|
108
|
+
const m = src.match(re);
|
|
109
|
+
if (m) {
|
|
110
|
+
const braceIdx = m.index + m[0].length - 1; // позиция "{"
|
|
111
|
+
return src.slice(0, braceIdx + 1) + insert + src.slice(braceIdx + 1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Гарантирует, что папка блога попадёт в serverless-бандл Vercel (Next `outputFileTracingIncludes`).
|
|
118
|
+
// ЗАЧЕМ: блог читает content/blog через node:fs. На Vercel это надёжно только на СБОРКЕ. Но в РАНТАЙМЕ
|
|
119
|
+
// файлы читают и ISR-ревалидация страниц блога, и route-handler карты сайта (/sitemap/<раздел>.xml —
|
|
120
|
+
// он всегда рантайм). Трассировщик Next не видит динамический readdirSync(cwd + env) → content/blog не
|
|
121
|
+
// кладётся в лямбду → в рантайме там пусто → статьи схлопываются в 404/«нет статей», блога нет в sitemap.
|
|
122
|
+
// Этот ключ форсит включение content/blog в бандл функций. Идемпотентно; best-effort (никогда не роняет).
|
|
123
|
+
// Живёт в CLI (а не в разовой инструкции), чтобы self-update (`npx … blog --force`) авто-чинил ЛЮБОЙ сайт
|
|
124
|
+
// без участия разработчика сайта.
|
|
125
|
+
async function ensureContentTracing(contentDir = "content/blog") {
|
|
126
|
+
const glob = `./${contentDir.replace(/^\.?\/+/, "").replace(/\/+$/, "")}/**/*`;
|
|
127
|
+
const names = ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.cjs"];
|
|
128
|
+
let cfgPath = null;
|
|
129
|
+
for (const n of names) { if (await exists(path.join(root, n))) { cfgPath = path.join(root, n); break; } }
|
|
130
|
+
const manual = `\n \x1b[33mℹ\x1b[0m Добавь в next.config, чтобы блог работал на Vercel (ISR + карта сайта читают файлы в рантайме):\n \x1b[36moutputFileTracingIncludes: { "/**": ["${glob}"] }\x1b[0m`;
|
|
131
|
+
if (!cfgPath) { log(manual); return; }
|
|
132
|
+
const rel = path.relative(root, cfgPath);
|
|
133
|
+
let src = await fs.readFile(cfgPath, "utf8");
|
|
134
|
+
if (/outputFileTracingIncludes/.test(src)) {
|
|
135
|
+
if (src.includes(glob) || /content\/blog/.test(src)) { skip(`${rel} — трассировка блога уже настроена`); return; }
|
|
136
|
+
log(`\n \x1b[33mℹ\x1b[0m В ${rel} уже есть outputFileTracingIncludes — добавь в него ключ "${glob}".`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const insert = `\n // SEO Agent: включаем файлы блога (content/blog) в serverless-бандл — нужно для ISR-ревалидации\n // страниц блога и route-handler карты сайта, которые читают эти файлы в рантайме на Vercel.\n outputFileTracingIncludes: {\n "/**": ["${glob}"],\n },`;
|
|
140
|
+
const patched = injectIntoConfigObject(src, insert);
|
|
141
|
+
if (!patched) { log(`\n \x1b[33mℹ\x1b[0m Не смог авто-обновить ${rel}.${manual}`); return; }
|
|
142
|
+
await fs.writeFile(cfgPath, patched);
|
|
143
|
+
ok(`${rel} (outputFileTracingIncludes → content/blog в бандле)`);
|
|
144
|
+
}
|
|
145
|
+
|
|
98
146
|
async function init() {
|
|
99
147
|
log("\n\x1b[1m@webnumseoagent/next — установка\x1b[0m\n");
|
|
100
148
|
// Проверяем чистоту git ДО создания файлов (чтобы весь дифф установки был обозрим).
|
|
@@ -406,7 +454,7 @@ async function init() {
|
|
|
406
454
|
// generateMetadata: у блог-роута свой generateMetadata (seoBlogPost), его НЕ оборачиваем seoMeta.
|
|
407
455
|
if (!args.includes("--no-blog")) {
|
|
408
456
|
const jsxExt = ext === "ts" ? "tsx" : "jsx";
|
|
409
|
-
const mdParam = ext === "ts" ? "{ params }: { params: { slug: string } }" : "{ params }";
|
|
457
|
+
const mdParam = ext === "ts" ? "{ params }: { params: Promise<{ slug: string }> }" : "{ params }";
|
|
410
458
|
const madeIndex = await ensureFile(
|
|
411
459
|
path.join(root, appDir, `blog/page.${jsxExt}`),
|
|
412
460
|
`import { SeoBlogIndex } from "${blogFor("blog/page.tsx")}";\nexport const dynamic = "force-static";\nexport default function BlogPage() {\n return <SeoBlogIndex title="Блог" />;\n}\n`,
|
|
@@ -426,7 +474,7 @@ async function init() {
|
|
|
426
474
|
} else {
|
|
427
475
|
madeArticle = await ensureFile(
|
|
428
476
|
path.join(root, appDir, `blog/[slug]/page.${jsxExt}`),
|
|
429
|
-
`import { notFound } from "next/navigation";\nimport { seoBlogPost, seoBlogStaticParams, SeoBlogArticle } from "${blogFor("blog/[slug]/page.tsx")}";\nexport const dynamic = "force-static";\nexport const dynamicParams = false;\n\n// Полностью статический рендер: все статьи пре-рендерятся на сборке из файлов content/blog. Контент\n// меняется git-коммитом → редеплой. dynamicParams=false: неизвестный слаг → 404 (без рантайм-чтения).\nexport async function generateStaticParams() {\n return seoBlogStaticParams();\n}\n\nexport async function generateMetadata(${mdParam}) {\n const post = await seoBlogPost(
|
|
477
|
+
`import { notFound } from "next/navigation";\nimport { seoBlogPost, seoBlogStaticParams, SeoBlogArticle } from "${blogFor("blog/[slug]/page.tsx")}";\nexport const dynamic = "force-static";\nexport const dynamicParams = false;\n\n// Полностью статический рендер: все статьи пре-рендерятся на сборке из файлов content/blog. Контент\n// меняется git-коммитом → редеплой. dynamicParams=false: неизвестный слаг → 404 (без рантайм-чтения).\nexport async function generateStaticParams() {\n return seoBlogStaticParams();\n}\n\nexport async function generateMetadata(${mdParam}) {\n const { slug } = await params;\n const post = await seoBlogPost(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 { slug } = await params;\n const post = await seoBlogPost(slug);\n if (!post) notFound();\n return <SeoBlogArticle post={post} />;\n}\n`,
|
|
430
478
|
);
|
|
431
479
|
}
|
|
432
480
|
if (madeIndex || madeArticle) {
|
|
@@ -434,6 +482,8 @@ async function init() {
|
|
|
434
482
|
} else {
|
|
435
483
|
log(`\n \x1b[33mℹ\x1b[0m Блог: у сайта уже есть роуты /blog — не трогаю.`);
|
|
436
484
|
}
|
|
485
|
+
// Файлы блога должны попасть в serverless-бандл Vercel (иначе рантайм-чтение в лямбде пусто).
|
|
486
|
+
await ensureContentTracing();
|
|
437
487
|
}
|
|
438
488
|
|
|
439
489
|
log("\n\x1b[1mЗатем:\x1b[0m задай эти же env-переменные в Vercel (Settings → Environment Variables) и сделай git push.\n");
|
|
@@ -694,33 +744,42 @@ async function blogPage() {
|
|
|
694
744
|
// index-роут (список): в [locale] прокидываем locale + basePath. СТАТИЧЕСКИЙ (Фаза 1 git-native):
|
|
695
745
|
// без searchParams (иначе роут стал бы динамическим и в рантайме Vercel не увидел бы файлы content/blog).
|
|
696
746
|
const idxSig = param
|
|
697
|
-
? (ext === "ts" ? `{ params }: { params: { ${param}: string } }` : "{ params }")
|
|
747
|
+
? (ext === "ts" ? `{ params }: { params: Promise<{ ${param}: string }> }` : "{ params }")
|
|
698
748
|
: "";
|
|
749
|
+
// Next 15: params — Promise, читаем через await (в Next 14 await над объектом тоже безвреден).
|
|
750
|
+
// param==="locale" — деструктурим без переименования (иначе eslint no-useless-rename).
|
|
751
|
+
const idxAwait = param ? ` const { ${param === "locale" ? "locale" : `${param}: locale`} } = await params;\n` : "";
|
|
699
752
|
const idxBody = param
|
|
700
|
-
? `<SeoBlogIndex locale={
|
|
753
|
+
? `<SeoBlogIndex locale={locale} basePath={\`/\${locale}/blog\`} />`
|
|
701
754
|
: `<SeoBlogIndex />`;
|
|
702
755
|
await writeOurRoute(
|
|
703
756
|
path.join(root, appDir, under, `blog/page.${jsxExt}`),
|
|
704
|
-
`import { SeoBlogIndex } from "${blogFor(`${under}blog/page.tsx`)}";\nexport const dynamic = "force-static";\nexport default function BlogPage(${idxSig}) {\n return ${idxBody};\n}\n`,
|
|
757
|
+
`import { SeoBlogIndex } from "${blogFor(`${under}blog/page.tsx`)}";\nexport const dynamic = "force-static";\nexport default async function BlogPage(${idxSig}) {\n${idxAwait} return ${idxBody};\n}\n`,
|
|
705
758
|
);
|
|
706
759
|
|
|
707
760
|
if (blogDynConflict) {
|
|
708
761
|
log(`\n \x1b[1mУ сайта уже есть blog/[иной-сегмент]\x1b[0m — роут статьи /blog/[slug] не создаю. Подключи вручную: \x1b[36mimport { seoBlogPost, SeoBlogArticle } from "${blogFor(`${under}blog/[slug]/page.tsx`)}";\x1b[0m`);
|
|
709
762
|
} else {
|
|
710
763
|
const artSig = ext === "ts"
|
|
711
|
-
? (param ? `{ params }: { params: { ${param}: string; slug: string } }` : `{ params }: { params: { slug: string } }`)
|
|
764
|
+
? (param ? `{ params }: { params: Promise<{ ${param}: string; slug: string }> }` : `{ params }: { params: Promise<{ slug: string }> }`)
|
|
712
765
|
: "{ params }";
|
|
713
|
-
|
|
714
|
-
const
|
|
766
|
+
// Next 15: params — Promise, читаем через await. Локаль переименовываем в locale (кроме param==="locale").
|
|
767
|
+
const artAwait = param ? ` const { slug, ${param === "locale" ? "locale" : `${param}: locale`} } = await params;\n` : ` const { slug } = await params;\n`;
|
|
768
|
+
const localeArg = param ? `, { locale }` : "";
|
|
769
|
+
const localeProp = param ? ` locale={locale}` : "";
|
|
715
770
|
// Мультиязычный роут /[locale]/blog/[slug]: пре-рендерим КАЖДУЮ статью под КАЖДУЮ локаль (передаём имя
|
|
716
771
|
// сегмента локали) — иначе force-static не покроет комбинацию locale×slug и статьи отдадут 404.
|
|
717
772
|
const gsp = param ? `seoBlogStaticParams({ localeParam: "${param}" })` : `seoBlogStaticParams()`;
|
|
718
773
|
await writeOurRoute(
|
|
719
774
|
path.join(root, appDir, under, `blog/[slug]/page.${jsxExt}`),
|
|
720
|
-
`import { notFound } from "next/navigation";\nimport { seoBlogPost, seoBlogStaticParams, SeoBlogArticle } from "${blogFor(`${under}blog/[slug]/page.tsx`)}";\nexport const dynamic = "force-static";\nexport const dynamicParams = false;\n\n// Полностью статический рендер: все статьи пре-рендерятся на сборке из файлов content/blog. Контент\n// меняется git-коммитом → редеплой. dynamicParams=false: неизвестный слаг → 404 (без рантайм-чтения).\nexport async function generateStaticParams() {\n return ${gsp};\n}\n\nexport async function generateMetadata(${artSig}) {\n const post = await seoBlogPost(
|
|
775
|
+
`import { notFound } from "next/navigation";\nimport { seoBlogPost, seoBlogStaticParams, SeoBlogArticle } from "${blogFor(`${under}blog/[slug]/page.tsx`)}";\nexport const dynamic = "force-static";\nexport const dynamicParams = false;\n\n// Полностью статический рендер: все статьи пре-рендерятся на сборке из файлов content/blog. Контент\n// меняется git-коммитом → редеплой. dynamicParams=false: неизвестный слаг → 404 (без рантайм-чтения).\nexport async function generateStaticParams() {\n return ${gsp};\n}\n\nexport async function generateMetadata(${artSig}) {\n${artAwait} const post = await seoBlogPost(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${artAwait} const post = await seoBlogPost(slug${localeArg});\n if (!post) notFound();\n return <SeoBlogArticle post={post}${localeProp} />;\n}\n`,
|
|
721
776
|
);
|
|
722
777
|
}
|
|
723
778
|
|
|
779
|
+
// Файлы блога должны попасть в serverless-бандл Vercel (ISR-ревалидация + карта сайта читают их в
|
|
780
|
+
// рантайме). Идемпотентно — запускается и на self-update (`npx … blog --force`), авто-чиня сайт.
|
|
781
|
+
await ensureContentTracing();
|
|
782
|
+
|
|
724
783
|
log(`\n \x1b[32m✓\x1b[0m Готово. Проверь \x1b[36mgit diff\x1b[0m, затем commit + push. Блог: \x1b[1m${localeDir ? `/${param}/blog` : "/blog"}\x1b[0m — дизайн/шапка с сайта, список 4-в-ряд, статический рендер из файлов.\n`);
|
|
725
784
|
}
|
|
726
785
|
|
package/package.json
CHANGED