@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.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/bin/cli.mjs +68 -9
  3. 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(params.slug);\n if (!post) return {};\n return { title: post.title, description: post.excerpt, openGraph: { images: post.cover ? [post.cover] : [] } };\n}\n\nexport default async function BlogPostPage(${mdParam}) {\n const post = await seoBlogPost(params.slug);\n if (!post) notFound();\n return <SeoBlogArticle post={post} />;\n}\n`,
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={params.${param}} basePath={\`/\${params.${param}}/blog\`} />`
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
- const localeArg = param ? `, { locale: params.${param} }` : "";
714
- const localeProp = param ? ` locale={params.${param}}` : "";
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(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`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webnumseoagent/next",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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",