@webnumseoagent/next 0.1.2 → 0.1.4

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
@@ -79,6 +79,10 @@ async function init() {
79
79
  }
80
80
  ok(`Найден app-каталог: ${appDir}/`);
81
81
 
82
+ // Тип проекта: TS (есть tsconfig.json) или JS — от этого зависит расширение генерируемых файлов.
83
+ const ext = (await exists(path.join(root, "tsconfig.json"))) ? "ts" : "js";
84
+ ok(`Тип проекта: ${ext === "ts" ? "TypeScript" : "JavaScript"} (файлы .${ext})`);
85
+
82
86
  const locales = await detectLocales();
83
87
  if (locales.length) ok(`Обнаружены локали (next-intl): ${locales.join(", ")}`);
84
88
 
@@ -105,22 +109,39 @@ async function init() {
105
109
  importJsonld = `${imp}/jsonld`;
106
110
  }
107
111
 
112
+ // Не создаём sitemap/robots, если у сайта они УЖЕ есть (public/, отдельный роут и т.п.) —
113
+ // чтобы не перекрыть существующую карту сайта пустой.
114
+ const anyExists = async (paths) => {
115
+ for (const p of paths) if (await exists(path.join(root, p))) return true;
116
+ return false;
117
+ };
118
+
108
119
  // 2. sitemap.ts
109
- await ensureFile(
110
- path.join(root, appDir, "sitemap.ts"),
120
+ const hasSitemap = await anyExists([
121
+ "public/sitemap.xml", "public/sitemap_index.xml",
122
+ `${appDir}/sitemap.ts`, `${appDir}/sitemap.js`, `${appDir}/sitemap.xml/route.ts`, `${appDir}/sitemap.xml/route.js`,
123
+ ]);
124
+ if (hasSitemap) skip("sitemap (у сайта уже есть — не трогаем)");
125
+ else await ensureFile(
126
+ path.join(root, appDir, `sitemap.${ext}`),
111
127
  `import { seoSitemap } from "${importClient}";\nexport const revalidate = 300;\nexport default async function sitemap() {\n return seoSitemap();\n}\n`,
112
128
  );
113
129
 
114
- // 3. robots.ts
115
- await ensureFile(
116
- path.join(root, appDir, "robots.ts"),
130
+ // 3. robots
131
+ const hasRobots = await anyExists([
132
+ "public/robots.txt",
133
+ `${appDir}/robots.ts`, `${appDir}/robots.js`, `${appDir}/robots.txt/route.ts`, `${appDir}/robots.txt/route.js`,
134
+ ]);
135
+ if (hasRobots) skip("robots (у сайта уже есть — не трогаем)");
136
+ else await ensureFile(
137
+ path.join(root, appDir, `robots.${ext}`),
117
138
  `import { seoRobots } from "${importClient}";\nexport const revalidate = 300;\nexport default async function robots() {\n return seoRobots();\n}\n`,
118
139
  );
119
140
 
120
141
  // 4. revalidate-роут (мгновенное применение правок с платформы)
121
142
  await ensureFile(
122
- path.join(root, appDir, "api/seoagent-revalidate/route.ts"),
123
- `import { revalidateTag } from "next/cache";\n\nexport async function POST(req: Request) {\n if (req.headers.get("x-revalidate-secret") !== process.env.SEOAGENT_REVALIDATE_SECRET) {\n return new Response("unauthorized", { status: 401 });\n }\n revalidateTag(\`seo-\${process.env.SEOAGENT_SITE_ID}\`);\n return Response.json({ ok: true });\n}\n`,
143
+ path.join(root, appDir, `api/seoagent-revalidate/route.${ext}`),
144
+ `import { revalidateTag } from "next/cache";\n\nexport async function POST(req${ext === "ts" ? ": Request" : ""}) {\n if (req.headers.get("x-revalidate-secret") !== process.env.SEOAGENT_REVALIDATE_SECRET) {\n return new Response("unauthorized", { status: 401 });\n }\n revalidateTag(\`seo-\${process.env.SEOAGENT_SITE_ID}\`);\n return Response.json({ ok: true });\n}\n`,
124
145
  );
125
146
 
126
147
  // 5. .env.local
@@ -171,7 +192,7 @@ async function init() {
171
192
  const code = await fs.readFile(lp, "utf8");
172
193
  const r = await mountJsonLd(code, importJsonld);
173
194
  if (r.ok) { await fs.writeFile(lp, r.code); ok(`${path.relative(root, lp)} → смонтирован <SeoJsonLd/>`); jsonLdMounted = true; }
174
- else if (r.reason === "already") jsonLdMounted = true;
195
+ else if (r.reason === "already" || r.reason === "existing-jsonld") jsonLdMounted = true;
175
196
  }
176
197
  }
177
198
  } else {
@@ -199,7 +220,7 @@ async function findLayouts(dir, acc = []) {
199
220
  if (e.isDirectory()) {
200
221
  if (e.name === "node_modules" || e.name === "api") continue;
201
222
  await findLayouts(p, acc);
202
- } else if (/^layout\.(tsx|ts)$/.test(e.name)) {
223
+ } else if (/^layout\.(tsx|ts|jsx|js)$/.test(e.name)) {
203
224
  acc.push(p);
204
225
  }
205
226
  }
@@ -236,9 +257,10 @@ async function findMetadataPages(dir, acc = []) {
236
257
  if (e.isDirectory()) {
237
258
  if (e.name === "node_modules" || e.name === "api") continue;
238
259
  await findMetadataPages(p, acc);
239
- } else if (/\.(tsx|ts)$/.test(e.name) && /page\.|layout\./.test(e.name)) {
260
+ } else if (/\.(tsx|ts|jsx|js)$/.test(e.name) && /page\.|layout\./.test(e.name)) {
240
261
  const txt = await fs.readFile(p, "utf8");
241
- if (/generateMetadata/.test(txt)) acc.push(p);
262
+ // Собираем и функции generateMetadata, и статический `export const metadata`.
263
+ if (/generateMetadata/.test(txt) || /export\s+const\s+metadata\b/.test(txt)) acc.push(p);
242
264
  }
243
265
  }
244
266
  return acc;
package/bin/wrap.mjs CHANGED
@@ -33,6 +33,8 @@ export async function mountJsonLd(code, importSpec) {
33
33
  return { ok: false, reason: "deps" };
34
34
  }
35
35
  if (/\bSeoJsonLd\b/.test(code)) return { ok: false, reason: "already" };
36
+ // На сайте уже есть свой JSON-LD — не дублируем (можно вручную заменить на <SeoJsonLd/>).
37
+ if (/application\/ld\+json/.test(code)) return { ok: false, reason: "existing-jsonld" };
36
38
  const plugins = ["typescript", "jsx"];
37
39
  const parser = { parse: (src) => babelParser.parse(src, { sourceType: "module", tokens: true, plugins }) };
38
40
  let ast;
@@ -97,30 +99,45 @@ export async function wrapFile(code, routeExpr, importSpec) {
97
99
  }
98
100
  }
99
101
  }
100
- if (!fn || !fn.body) return { ok: false, reason: "no-generateMetadata" };
101
- // locale опционально: включаем его в seoMeta только если он есть в scope
102
- // (мультиязычные сайты), для одноязычныхопускаем.
103
- const hasLocale = /\blocale\b/.test(code);
104
-
105
- // Динамические параметры маршрута (напр. slug) должны быть в scope.
106
- for (const m of routeExpr.matchAll(/\$\{(\w+)\}/g)) {
107
- if (!new RegExp("\\b" + m[1] + "\\b").test(code)) return { ok: false, reason: "no-param:" + m[1] };
102
+ if (fn && fn.body) {
103
+ // ── Путь 1: функция generateMetadata оборачиваем её последний object-return ──
104
+ // locale опционально (мультиязычные есть, одноязычныенет).
105
+ const hasLocale = /\blocale\b/.test(code);
106
+ for (const m of routeExpr.matchAll(/\$\{(\w+)\}/g)) {
107
+ if (!new RegExp("\\b" + m[1] + "\\b").test(code)) return { ok: false, reason: "no-param:" + m[1] };
108
+ }
109
+ const returns = collectReturns(fn.body, fn);
110
+ if (!returns.length) return { ok: false, reason: "no-object-return" };
111
+ const target = returns[returns.length - 1];
112
+ let call;
113
+ try {
114
+ const localePart = hasLocale ? "locale, " : "";
115
+ call = babelParser.parseExpression(`seoMeta({ route: ${routeExpr}, ${localePart}fallback: null })`, { plugins });
116
+ } catch { return { ok: false, reason: "route-expr" }; }
117
+ call.arguments[0].properties.find((p) => p.key && p.key.name === "fallback").value = target.argument;
118
+ target.argument = call;
119
+ } else {
120
+ // ── Путь 2: статический `export const metadata = {…}` → конвертируем в generateMetadata ──
121
+ if (/\$\{/.test(routeExpr)) return { ok: false, reason: "static-metadata-dynamic" };
122
+ let metaExport = null, metaObj = null;
123
+ for (const n of body) {
124
+ if (n.type === "ExportNamedDeclaration" && n.declaration && n.declaration.type === "VariableDeclaration") {
125
+ for (const decl of n.declaration.declarations) {
126
+ if (decl.id && decl.id.name === "metadata" && decl.init && decl.init.type === "ObjectExpression") {
127
+ metaExport = n; metaObj = decl.init;
128
+ }
129
+ }
130
+ }
131
+ }
132
+ if (!metaObj) return { ok: false, reason: "no-metadata" };
133
+ let fnAst;
134
+ try {
135
+ fnAst = babelParser.parse(`export async function generateMetadata() {\n return seoMeta({ route: ${routeExpr}, fallback: null });\n}`, { sourceType: "module", plugins }).program.body[0];
136
+ } catch { return { ok: false, reason: "route-expr" }; }
137
+ fnAst.declaration.body.body[0].argument.arguments[0].properties.find((p) => p.key && p.key.name === "fallback").value = metaObj;
138
+ body[body.indexOf(metaExport)] = fnAst;
108
139
  }
109
140
 
110
- const returns = collectReturns(fn.body, fn);
111
- if (!returns.length) return { ok: false, reason: "no-object-return" };
112
- const target = returns[returns.length - 1];
113
-
114
- // Строим seoMeta({ route, [locale,] fallback: <оригинальный объект> })
115
- let call;
116
- try {
117
- const localePart = hasLocale ? "locale, " : "";
118
- call = babelParser.parseExpression(`seoMeta({ route: ${routeExpr}, ${localePart}fallback: null })`, { plugins });
119
- } catch { return { ok: false, reason: "route-expr" }; }
120
- const fbProp = call.arguments[0].properties.find((p) => p.key && p.key.name === "fallback");
121
- fbProp.value = target.argument; // подставляем прежний объект как fallback
122
- target.argument = call;
123
-
124
141
  // Импорт seoMeta
125
142
  const hasImport = body.some(
126
143
  (n) => n.type === "ImportDeclaration" && n.source.value === importSpec && n.specifiers.some((s) => s.imported && s.imported.name === "seoMeta"),
package/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ "use strict";
1
2
  // @seoagent/next — рантайм-адаптер SEO для Next.js (App Router).
2
3
  // Generic: работает для ЛЮБОГО сайта — всё определяется env-переменными.
3
4
  // Копируется в сайт-клиент (или ставится как пакет). Ничего не хардкодит.
@@ -9,6 +10,11 @@
9
10
  //
10
11
  // Все хелперы FAIL-SAFE: при ошибке/таймауте возвращают переданный fallback
11
12
  // (скомпилированные дефолты сайта) — сборка/рендер НИКОГДА не падает и НЕ уходит в noindex.
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.seoMeta = seoMeta;
15
+ exports.seoSitemap = seoSitemap;
16
+ exports.seoRobots = seoRobots;
17
+ exports.seoJsonLd = seoJsonLd;
12
18
  const API = process.env.SEOAGENT_API_BASE ?? "";
13
19
  const SITE = process.env.SEOAGENT_SITE_ID ?? "";
14
20
  const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
@@ -69,7 +75,7 @@ function mergeIntoMetadata(fallback, f) {
69
75
  return m;
70
76
  }
71
77
  // В generateMetadata: const md = await seoMeta({ route, locale, fallback: compiled });
72
- export async function seoMeta(opts) {
78
+ async function seoMeta(opts) {
73
79
  const fallback = opts.fallback ?? {};
74
80
  const data = await fetchJson(`/config?route=${encodeURIComponent(opts.route)}&locale=${encodeURIComponent(opts.locale ?? "")}`);
75
81
  if (!data?.fields)
@@ -77,7 +83,7 @@ export async function seoMeta(opts) {
77
83
  return mergeIntoMetadata(fallback, data.fields);
78
84
  }
79
85
  // app/sitemap.ts: export default async function sitemap() { return seoSitemap() }
80
- export async function seoSitemap() {
86
+ async function seoSitemap() {
81
87
  const data = await fetchJson("/sitemap");
82
88
  if (!data?.entries)
83
89
  return [];
@@ -88,13 +94,13 @@ export async function seoSitemap() {
88
94
  }));
89
95
  }
90
96
  // app/robots.ts: export default async function robots() { return seoRobots() }
91
- export async function seoRobots() {
97
+ async function seoRobots() {
92
98
  const data = await fetchJson("/robots");
93
99
  // Fail-safe: при недоступности отдаём разрешающий robots (никогда не Disallow: /).
94
100
  return data ?? { rules: [{ userAgent: "*", allow: "/" }] };
95
101
  }
96
102
  // Для JSON-LD берём массив узлов конфига данного маршрута/локали (см. jsonld.tsx).
97
- export async function seoJsonLd(route, locale) {
103
+ async function seoJsonLd(route, locale) {
98
104
  const data = await fetchJson(`/config?route=${encodeURIComponent(route)}&locale=${encodeURIComponent(locale ?? "")}`);
99
105
  return data?.fields?.jsonLd ?? [];
100
106
  }
package/dist/jsonld.js CHANGED
@@ -1,8 +1,11 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SeoJsonLd = SeoJsonLd;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
2
5
  // Серверный компонент для инъекции JSON-LD. XSS-безопасно: экранируем '<' → <
3
6
  // и не даём закрыть тег </script>. Смонтируй в layout и/или на страницах:
4
7
  // <SeoJsonLd route="/" locale={locale} />
5
- import { seoJsonLd } from "./client";
8
+ const client_1 = require("./client");
6
9
  function safeJson(nodes) {
7
10
  // Один граф Schema.org; экранируем опасные последовательности.
8
11
  const graph = nodes.length === 1 ? nodes[0] : { "@context": "https://schema.org", "@graph": nodes };
@@ -11,11 +14,11 @@ function safeJson(nodes) {
11
14
  .replace(/>/g, "\\u003e")
12
15
  .replace(/&/g, "\\u0026");
13
16
  }
14
- export async function SeoJsonLd({ route, locale }) {
15
- const nodes = await seoJsonLd(route, locale);
17
+ async function SeoJsonLd({ route, locale }) {
18
+ const nodes = await (0, client_1.seoJsonLd)(route, locale);
16
19
  if (!nodes.length)
17
20
  return null;
18
- return (_jsx("script", { type: "application/ld+json",
21
+ return ((0, jsx_runtime_1.jsx)("script", { type: "application/ld+json",
19
22
  // eslint-disable-next-line react/no-danger
20
23
  dangerouslySetInnerHTML: { __html: safeJson(nodes) } }));
21
24
  }
package/package.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "name": "@webnumseoagent/next",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Runtime SEO adapter + installer for Next.js App Router sites (SEO Agent platform)",
5
- "type": "module",
6
5
  "main": "dist/client.js",
7
6
  "types": "dist/client.d.ts",
8
7
  "bin": {