@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 +33 -11
- package/bin/wrap.mjs +39 -22
- package/dist/client.js +10 -4
- package/dist/jsonld.js +8 -5
- package/package.json +1 -2
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
|
|
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
|
|
@@ -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
|
-
|
|
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 (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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": {
|