@webnumseoagent/next 0.1.1 → 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
@@ -8,7 +8,7 @@ import { promises as fs } from "node:fs";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { execSync } from "node:child_process";
11
- import { wrapFile } from "./wrap.mjs";
11
+ import { wrapFile, mountJsonLd } from "./wrap.mjs";
12
12
 
13
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
14
  const pkgRoot = path.resolve(__dirname, "..");
@@ -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
@@ -140,6 +161,7 @@ async function init() {
140
161
  // 6. generateMetadata: авто-обёртка (--wrap) или готовые сниппеты
141
162
  const pages = await findMetadataPages(path.join(root, appDir));
142
163
  const wrapMode = args.includes("--wrap");
164
+ let jsonLdMounted = false;
143
165
 
144
166
  if (wrapMode) {
145
167
  log("\n\x1b[1mАвто-обёртка generateMetadata:\x1b[0m");
@@ -161,6 +183,18 @@ async function init() {
161
183
  log(` \x1b[36mimport { seoMeta } from "${importClient}";\n return seoMeta({ route: ${m.expr}, locale, fallback: { /* прежний объект */ } });\x1b[0m`);
162
184
  }
163
185
  }
186
+
187
+ // Авто-монтирование <SeoJsonLd/> в layout (человек код не трогает).
188
+ // Отключить можно флагом --no-mount-jsonld.
189
+ if (!args.includes("--no-mount-jsonld")) {
190
+ const layouts = await findLayouts(path.join(root, appDir));
191
+ for (const lp of layouts) {
192
+ const code = await fs.readFile(lp, "utf8");
193
+ const r = await mountJsonLd(code, importJsonld);
194
+ if (r.ok) { await fs.writeFile(lp, r.code); ok(`${path.relative(root, lp)} → смонтирован <SeoJsonLd/>`); jsonLdMounted = true; }
195
+ else if (r.reason === "already" || r.reason === "existing-jsonld") jsonLdMounted = true;
196
+ }
197
+ }
164
198
  } else {
165
199
  log(`\n \x1b[1mimport { seoMeta } from "${importClient}";\x1b[0m — добавь этот импорт в файлы ниже.`);
166
200
  log("\n\x1b[1mОсталось вручную: в каждой странице замени `return { …меты }` на строку из блока (route уже посчитан):\x1b[0m");
@@ -173,10 +207,26 @@ async function init() {
173
207
  }
174
208
  }
175
209
 
176
- log(`\n И смонтируй JSON-LD в layout (внутри <body>):\n \x1b[36mimport { SeoJsonLd } from "${importJsonld}";\n <SeoJsonLd route="/" />\x1b[0m`);
210
+ if (!jsonLdMounted) {
211
+ log(`\n И смонтируй JSON-LD в layout (внутри <body>):\n \x1b[36mimport { SeoJsonLd } from "${importJsonld}";\n <SeoJsonLd route="/" />\x1b[0m`);
212
+ }
177
213
  log("\n\x1b[1mЗатем:\x1b[0m задай эти же env-переменные в Vercel (Settings → Environment Variables) и сделай git push.\n");
178
214
  }
179
215
 
216
+ // Ищет файлы layout.(tsx|ts) под app/.
217
+ async function findLayouts(dir, acc = []) {
218
+ for (const e of await fs.readdir(dir, { withFileTypes: true })) {
219
+ const p = path.join(dir, e.name);
220
+ if (e.isDirectory()) {
221
+ if (e.name === "node_modules" || e.name === "api") continue;
222
+ await findLayouts(p, acc);
223
+ } else if (/^layout\.(tsx|ts|jsx|js)$/.test(e.name)) {
224
+ acc.push(p);
225
+ }
226
+ }
227
+ return acc;
228
+ }
229
+
180
230
  function isGitClean() {
181
231
  try {
182
232
  return execSync("git status --porcelain", { cwd: root, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() === "";
@@ -207,9 +257,10 @@ async function findMetadataPages(dir, acc = []) {
207
257
  if (e.isDirectory()) {
208
258
  if (e.name === "node_modules" || e.name === "api") continue;
209
259
  await findMetadataPages(p, acc);
210
- } 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)) {
211
261
  const txt = await fs.readFile(p, "utf8");
212
- 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);
213
264
  }
214
265
  }
215
266
  return acc;
package/bin/wrap.mjs CHANGED
@@ -22,6 +22,56 @@ function collectReturns(fnBody, fn) {
22
22
  return out;
23
23
  }
24
24
 
25
+ // Монтирует <SeoJsonLd route="/" /> в <body> layout + добавляет импорт.
26
+ // Best-effort: если <body> не найден или уже смонтировано — {ok:false, reason}.
27
+ export async function mountJsonLd(code, importSpec) {
28
+ let recast, babelParser;
29
+ try {
30
+ recast = await import("recast");
31
+ babelParser = await import("@babel/parser");
32
+ } catch {
33
+ return { ok: false, reason: "deps" };
34
+ }
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" };
38
+ const plugins = ["typescript", "jsx"];
39
+ const parser = { parse: (src) => babelParser.parse(src, { sourceType: "module", tokens: true, plugins }) };
40
+ let ast;
41
+ try { ast = recast.parse(code, { parser }); } catch { return { ok: false, reason: "parse" }; }
42
+
43
+ // Ищем JSXElement <body>.
44
+ let body = null;
45
+ (function walk(node) {
46
+ if (body || !node || typeof node !== "object") return;
47
+ if (Array.isArray(node)) return node.forEach(walk);
48
+ if (node.type === "JSXElement" && node.openingElement?.name?.name === "body") { body = node; return; }
49
+ for (const k in node) {
50
+ if (k === "loc" || k === "tokens" || k === "comments") continue;
51
+ const v = node[k];
52
+ if (v && typeof v === "object") walk(v);
53
+ }
54
+ })(ast.program);
55
+ if (!body) return { ok: false, reason: "no-body" };
56
+
57
+ let el;
58
+ try { el = babelParser.parseExpression(`<SeoJsonLd route="/" />`, { plugins }); } catch { return { ok: false, reason: "el" }; }
59
+ body.children.push(el);
60
+
61
+ const has = ast.program.body.some(
62
+ (n) => n.type === "ImportDeclaration" && n.source.value === importSpec && n.specifiers.some((s) => s.imported && s.imported.name === "SeoJsonLd"),
63
+ );
64
+ if (!has) {
65
+ const imp = babelParser.parse(`import { SeoJsonLd } from "${importSpec}";`, { sourceType: "module", plugins }).program.body[0];
66
+ ast.program.body.unshift(imp);
67
+ }
68
+
69
+ let out;
70
+ try { out = recast.print(ast).code; } catch { return { ok: false, reason: "print" }; }
71
+ try { babelParser.parse(out, { sourceType: "module", plugins }); } catch { return { ok: false, reason: "invalid-output" }; }
72
+ return { ok: true, code: out };
73
+ }
74
+
25
75
  export async function wrapFile(code, routeExpr, importSpec) {
26
76
  let recast, babelParser;
27
77
  try {
@@ -49,30 +99,45 @@ export async function wrapFile(code, routeExpr, importSpec) {
49
99
  }
50
100
  }
51
101
  }
52
- if (!fn || !fn.body) return { ok: false, reason: "no-generateMetadata" };
53
- // locale опционально: включаем его в seoMeta только если он есть в scope
54
- // (мультиязычные сайты), для одноязычныхопускаем.
55
- const hasLocale = /\blocale\b/.test(code);
56
-
57
- // Динамические параметры маршрута (напр. slug) должны быть в scope.
58
- for (const m of routeExpr.matchAll(/\$\{(\w+)\}/g)) {
59
- 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;
60
139
  }
61
140
 
62
- const returns = collectReturns(fn.body, fn);
63
- if (!returns.length) return { ok: false, reason: "no-object-return" };
64
- const target = returns[returns.length - 1];
65
-
66
- // Строим seoMeta({ route, [locale,] fallback: <оригинальный объект> })
67
- let call;
68
- try {
69
- const localePart = hasLocale ? "locale, " : "";
70
- call = babelParser.parseExpression(`seoMeta({ route: ${routeExpr}, ${localePart}fallback: null })`, { plugins });
71
- } catch { return { ok: false, reason: "route-expr" }; }
72
- const fbProp = call.arguments[0].properties.find((p) => p.key && p.key.name === "fallback");
73
- fbProp.value = target.argument; // подставляем прежний объект как fallback
74
- target.argument = call;
75
-
76
141
  // Импорт seoMeta
77
142
  const hasImport = body.some(
78
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.1",
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": {