@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 +62 -11
- package/bin/wrap.mjs +87 -22
- package/dist/client.js +10 -4
- package/dist/jsonld.js +8 -5
- package/package.json +1 -2
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
|
|
110
|
-
|
|
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
|
|
115
|
-
await
|
|
116
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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": {
|