@webnumseoagent/next 0.1.0 → 0.1.2

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, "..");
@@ -140,6 +140,7 @@ async function init() {
140
140
  // 6. generateMetadata: авто-обёртка (--wrap) или готовые сниппеты
141
141
  const pages = await findMetadataPages(path.join(root, appDir));
142
142
  const wrapMode = args.includes("--wrap");
143
+ let jsonLdMounted = false;
143
144
 
144
145
  if (wrapMode) {
145
146
  log("\n\x1b[1mАвто-обёртка generateMetadata:\x1b[0m");
@@ -161,6 +162,18 @@ async function init() {
161
162
  log(` \x1b[36mimport { seoMeta } from "${importClient}";\n return seoMeta({ route: ${m.expr}, locale, fallback: { /* прежний объект */ } });\x1b[0m`);
162
163
  }
163
164
  }
165
+
166
+ // Авто-монтирование <SeoJsonLd/> в layout (человек код не трогает).
167
+ // Отключить можно флагом --no-mount-jsonld.
168
+ if (!args.includes("--no-mount-jsonld")) {
169
+ const layouts = await findLayouts(path.join(root, appDir));
170
+ for (const lp of layouts) {
171
+ const code = await fs.readFile(lp, "utf8");
172
+ const r = await mountJsonLd(code, importJsonld);
173
+ 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;
175
+ }
176
+ }
164
177
  } else {
165
178
  log(`\n \x1b[1mimport { seoMeta } from "${importClient}";\x1b[0m — добавь этот импорт в файлы ниже.`);
166
179
  log("\n\x1b[1mОсталось вручную: в каждой странице замени `return { …меты }` на строку из блока (route уже посчитан):\x1b[0m");
@@ -173,10 +186,26 @@ async function init() {
173
186
  }
174
187
  }
175
188
 
176
- log(`\n И смонтируй JSON-LD в layout (внутри <body>):\n \x1b[36mimport { SeoJsonLd } from "${importJsonld}";\n <SeoJsonLd route="/" locale={locale} />\x1b[0m`);
189
+ if (!jsonLdMounted) {
190
+ log(`\n И смонтируй JSON-LD в layout (внутри <body>):\n \x1b[36mimport { SeoJsonLd } from "${importJsonld}";\n <SeoJsonLd route="/" />\x1b[0m`);
191
+ }
177
192
  log("\n\x1b[1mЗатем:\x1b[0m задай эти же env-переменные в Vercel (Settings → Environment Variables) и сделай git push.\n");
178
193
  }
179
194
 
195
+ // Ищет файлы layout.(tsx|ts) под app/.
196
+ async function findLayouts(dir, acc = []) {
197
+ for (const e of await fs.readdir(dir, { withFileTypes: true })) {
198
+ const p = path.join(dir, e.name);
199
+ if (e.isDirectory()) {
200
+ if (e.name === "node_modules" || e.name === "api") continue;
201
+ await findLayouts(p, acc);
202
+ } else if (/^layout\.(tsx|ts)$/.test(e.name)) {
203
+ acc.push(p);
204
+ }
205
+ }
206
+ return acc;
207
+ }
208
+
180
209
  function isGitClean() {
181
210
  try {
182
211
  return execSync("git status --porcelain", { cwd: root, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() === "";
package/bin/wrap.mjs CHANGED
@@ -22,6 +22,54 @@ 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
+ const plugins = ["typescript", "jsx"];
37
+ const parser = { parse: (src) => babelParser.parse(src, { sourceType: "module", tokens: true, plugins }) };
38
+ let ast;
39
+ try { ast = recast.parse(code, { parser }); } catch { return { ok: false, reason: "parse" }; }
40
+
41
+ // Ищем JSXElement <body>.
42
+ let body = null;
43
+ (function walk(node) {
44
+ if (body || !node || typeof node !== "object") return;
45
+ if (Array.isArray(node)) return node.forEach(walk);
46
+ if (node.type === "JSXElement" && node.openingElement?.name?.name === "body") { body = node; return; }
47
+ for (const k in node) {
48
+ if (k === "loc" || k === "tokens" || k === "comments") continue;
49
+ const v = node[k];
50
+ if (v && typeof v === "object") walk(v);
51
+ }
52
+ })(ast.program);
53
+ if (!body) return { ok: false, reason: "no-body" };
54
+
55
+ let el;
56
+ try { el = babelParser.parseExpression(`<SeoJsonLd route="/" />`, { plugins }); } catch { return { ok: false, reason: "el" }; }
57
+ body.children.push(el);
58
+
59
+ const has = ast.program.body.some(
60
+ (n) => n.type === "ImportDeclaration" && n.source.value === importSpec && n.specifiers.some((s) => s.imported && s.imported.name === "SeoJsonLd"),
61
+ );
62
+ if (!has) {
63
+ const imp = babelParser.parse(`import { SeoJsonLd } from "${importSpec}";`, { sourceType: "module", plugins }).program.body[0];
64
+ ast.program.body.unshift(imp);
65
+ }
66
+
67
+ let out;
68
+ try { out = recast.print(ast).code; } catch { return { ok: false, reason: "print" }; }
69
+ try { babelParser.parse(out, { sourceType: "module", plugins }); } catch { return { ok: false, reason: "invalid-output" }; }
70
+ return { ok: true, code: out };
71
+ }
72
+
25
73
  export async function wrapFile(code, routeExpr, importSpec) {
26
74
  let recast, babelParser;
27
75
  try {
@@ -50,7 +98,9 @@ export async function wrapFile(code, routeExpr, importSpec) {
50
98
  }
51
99
  }
52
100
  if (!fn || !fn.body) return { ok: false, reason: "no-generateMetadata" };
53
- if (!/\blocale\b/.test(code)) return { ok: false, reason: "no-locale-binding" };
101
+ // locale опционально: включаем его в seoMeta только если он есть в scope
102
+ // (мультиязычные сайты), для одноязычных — опускаем.
103
+ const hasLocale = /\blocale\b/.test(code);
54
104
 
55
105
  // Динамические параметры маршрута (напр. slug) должны быть в scope.
56
106
  for (const m of routeExpr.matchAll(/\$\{(\w+)\}/g)) {
@@ -61,10 +111,11 @@ export async function wrapFile(code, routeExpr, importSpec) {
61
111
  if (!returns.length) return { ok: false, reason: "no-object-return" };
62
112
  const target = returns[returns.length - 1];
63
113
 
64
- // Строим seoMeta({ route, locale, fallback: <оригинальный объект> })
114
+ // Строим seoMeta({ route, [locale,] fallback: <оригинальный объект> })
65
115
  let call;
66
116
  try {
67
- call = babelParser.parseExpression(`seoMeta({ route: ${routeExpr}, locale, fallback: null })`, { plugins });
117
+ const localePart = hasLocale ? "locale, " : "";
118
+ call = babelParser.parseExpression(`seoMeta({ route: ${routeExpr}, ${localePart}fallback: null })`, { plugins });
68
119
  } catch { return { ok: false, reason: "route-expr" }; }
69
120
  const fbProp = call.arguments[0].properties.find((p) => p.key && p.key.name === "fallback");
70
121
  fbProp.value = target.argument; // подставляем прежний объект как fallback
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webnumseoagent/next",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Runtime SEO adapter + installer for Next.js App Router sites (SEO Agent platform)",
5
5
  "type": "module",
6
6
  "main": "dist/client.js",